Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- src/additional-headers.js +250 -0
- src/byaf.js +449 -0
- src/character-card-parser.js +98 -0
- src/charx.js +399 -0
- src/command-line.js +363 -0
- src/config-init.js +253 -0
- src/constants.js +535 -0
- src/electron/Start.bat +6 -0
- src/electron/index.js +62 -0
- src/electron/package-lock.json +802 -0
- src/electron/package.json +16 -0
- src/electron/start.sh +11 -0
- src/endpoints/anthropic.js +66 -0
- src/endpoints/assets.js +370 -0
- src/endpoints/avatars.js +65 -0
- src/endpoints/azure.js +88 -0
- src/endpoints/backends/chat-completions.js +0 -0
- src/endpoints/backends/kobold.js +281 -0
- src/endpoints/backends/text-completions.js +643 -0
- src/endpoints/backgrounds.js +76 -0
- src/endpoints/backups.js +75 -0
- src/endpoints/caption.js +29 -0
- src/endpoints/characters.js +1547 -0
- src/endpoints/chats.js +1020 -0
- src/endpoints/classify.js +55 -0
- src/endpoints/data-maid.js +816 -0
- src/endpoints/extensions.js +455 -0
- src/endpoints/files.js +101 -0
- src/endpoints/google.js +641 -0
- src/endpoints/groups.js +235 -0
- src/endpoints/horde.js +411 -0
- src/endpoints/images.js +155 -0
- src/endpoints/minimax.js +230 -0
- src/endpoints/moving-ui.js +17 -0
- src/endpoints/novelai.js +484 -0
- src/endpoints/openai.js +799 -0
- src/endpoints/openrouter.js +172 -0
- src/endpoints/presets.js +103 -0
- src/endpoints/quick-replies.js +32 -0
- src/endpoints/search.js +455 -0
- src/endpoints/secrets.js +635 -0
- src/endpoints/secure-generate.js +68 -0
- src/endpoints/settings.js +371 -0
- src/endpoints/speech.js +401 -0
- src/endpoints/sprites.js +290 -0
- src/endpoints/stable-diffusion.js +1822 -0
- src/endpoints/stats.js +469 -0
- src/endpoints/themes.js +38 -0
- src/endpoints/thumbnails.js +252 -0
- src/endpoints/tokenizers.js +1128 -0
src/additional-headers.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { TEXTGEN_TYPES, OPENROUTER_HEADERS, FEATHERLESS_HEADERS } from './constants.js';
|
| 2 |
+
import { SECRET_KEYS, readSecret } from './endpoints/secrets.js';
|
| 3 |
+
import { getConfigValue } from './util.js';
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Gets the headers for the Mancer API.
|
| 7 |
+
* @param {import('./users.js').UserDirectoryList} directories User directories
|
| 8 |
+
* @returns {object} Headers for the request
|
| 9 |
+
*/
|
| 10 |
+
function getMancerHeaders(directories) {
|
| 11 |
+
const apiKey = readSecret(directories, SECRET_KEYS.MANCER);
|
| 12 |
+
|
| 13 |
+
return apiKey ? ({
|
| 14 |
+
'X-API-KEY': apiKey,
|
| 15 |
+
'Authorization': `Bearer ${apiKey}`,
|
| 16 |
+
}) : {};
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/**
|
| 20 |
+
* Gets the headers for the TogetherAI API.
|
| 21 |
+
* @param {import('./users.js').UserDirectoryList} directories User directories
|
| 22 |
+
* @returns {object} Headers for the request
|
| 23 |
+
*/
|
| 24 |
+
function getTogetherAIHeaders(directories) {
|
| 25 |
+
const apiKey = readSecret(directories, SECRET_KEYS.TOGETHERAI);
|
| 26 |
+
|
| 27 |
+
return apiKey ? ({
|
| 28 |
+
'Authorization': `Bearer ${apiKey}`,
|
| 29 |
+
}) : {};
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* Gets the headers for the InfermaticAI API.
|
| 34 |
+
* @param {import('./users.js').UserDirectoryList} directories User directories
|
| 35 |
+
* @returns {object} Headers for the request
|
| 36 |
+
*/
|
| 37 |
+
function getInfermaticAIHeaders(directories) {
|
| 38 |
+
const apiKey = readSecret(directories, SECRET_KEYS.INFERMATICAI);
|
| 39 |
+
|
| 40 |
+
return apiKey ? ({
|
| 41 |
+
'Authorization': `Bearer ${apiKey}`,
|
| 42 |
+
}) : {};
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/**
|
| 46 |
+
* Gets the headers for the DreamGen API.
|
| 47 |
+
* @param {import('./users.js').UserDirectoryList} directories User directories
|
| 48 |
+
* @returns {object} Headers for the request
|
| 49 |
+
*/
|
| 50 |
+
function getDreamGenHeaders(directories) {
|
| 51 |
+
const apiKey = readSecret(directories, SECRET_KEYS.DREAMGEN);
|
| 52 |
+
|
| 53 |
+
return apiKey ? ({
|
| 54 |
+
'Authorization': `Bearer ${apiKey}`,
|
| 55 |
+
}) : {};
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/**
|
| 59 |
+
* Gets the headers for the OpenRouter API.
|
| 60 |
+
* @param {import('./users.js').UserDirectoryList} directories User directories
|
| 61 |
+
* @returns {object} Headers for the request
|
| 62 |
+
*/
|
| 63 |
+
function getOpenRouterHeaders(directories) {
|
| 64 |
+
const apiKey = readSecret(directories, SECRET_KEYS.OPENROUTER);
|
| 65 |
+
const baseHeaders = { ...OPENROUTER_HEADERS };
|
| 66 |
+
|
| 67 |
+
return apiKey ? Object.assign(baseHeaders, { 'Authorization': `Bearer ${apiKey}` }) : baseHeaders;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/**
|
| 71 |
+
* Gets the headers for the vLLM API.
|
| 72 |
+
* @param {import('./users.js').UserDirectoryList} directories User directories
|
| 73 |
+
* @returns {object} Headers for the request
|
| 74 |
+
*/
|
| 75 |
+
function getVllmHeaders(directories) {
|
| 76 |
+
const apiKey = readSecret(directories, SECRET_KEYS.VLLM);
|
| 77 |
+
|
| 78 |
+
return apiKey ? ({
|
| 79 |
+
'Authorization': `Bearer ${apiKey}`,
|
| 80 |
+
}) : {};
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/**
|
| 84 |
+
* Gets the headers for the Aphrodite API.
|
| 85 |
+
* @param {import('./users.js').UserDirectoryList} directories User directories
|
| 86 |
+
* @returns {object} Headers for the request
|
| 87 |
+
*/
|
| 88 |
+
function getAphroditeHeaders(directories) {
|
| 89 |
+
const apiKey = readSecret(directories, SECRET_KEYS.APHRODITE);
|
| 90 |
+
|
| 91 |
+
return apiKey ? ({
|
| 92 |
+
'X-API-KEY': apiKey,
|
| 93 |
+
'Authorization': `Bearer ${apiKey}`,
|
| 94 |
+
}) : {};
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
/**
|
| 98 |
+
* Gets the headers for the Tabby API.
|
| 99 |
+
* @param {import('./users.js').UserDirectoryList} directories User directories
|
| 100 |
+
* @returns {object} Headers for the request
|
| 101 |
+
*/
|
| 102 |
+
function getTabbyHeaders(directories) {
|
| 103 |
+
const apiKey = readSecret(directories, SECRET_KEYS.TABBY);
|
| 104 |
+
|
| 105 |
+
return apiKey ? ({
|
| 106 |
+
'x-api-key': apiKey,
|
| 107 |
+
'Authorization': `Bearer ${apiKey}`,
|
| 108 |
+
}) : {};
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
/**
|
| 112 |
+
* Gets the headers for the LlamaCPP API.
|
| 113 |
+
* @param {import('./users.js').UserDirectoryList} directories User directories
|
| 114 |
+
* @returns {object} Headers for the request
|
| 115 |
+
*/
|
| 116 |
+
function getLlamaCppHeaders(directories) {
|
| 117 |
+
const apiKey = readSecret(directories, SECRET_KEYS.LLAMACPP);
|
| 118 |
+
|
| 119 |
+
return apiKey ? ({
|
| 120 |
+
'Authorization': `Bearer ${apiKey}`,
|
| 121 |
+
}) : {};
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
/**
|
| 125 |
+
* Gets the headers for the Ooba API.
|
| 126 |
+
* @param {import('./users.js').UserDirectoryList} directories
|
| 127 |
+
* @returns {object} Headers for the request
|
| 128 |
+
*/
|
| 129 |
+
function getOobaHeaders(directories) {
|
| 130 |
+
const apiKey = readSecret(directories, SECRET_KEYS.OOBA);
|
| 131 |
+
|
| 132 |
+
return apiKey ? ({
|
| 133 |
+
'Authorization': `Bearer ${apiKey}`,
|
| 134 |
+
}) : {};
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
/**
|
| 138 |
+
* Gets the headers for the KoboldCpp API.
|
| 139 |
+
* @param {import('./users.js').UserDirectoryList} directories
|
| 140 |
+
* @returns {object} Headers for the request
|
| 141 |
+
*/
|
| 142 |
+
function getKoboldCppHeaders(directories) {
|
| 143 |
+
const apiKey = readSecret(directories, SECRET_KEYS.KOBOLDCPP);
|
| 144 |
+
|
| 145 |
+
return apiKey ? ({
|
| 146 |
+
'Authorization': `Bearer ${apiKey}`,
|
| 147 |
+
}) : {};
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
/**
|
| 151 |
+
* Gets the headers for the Featherless API.
|
| 152 |
+
* @param {import('./users.js').UserDirectoryList} directories
|
| 153 |
+
* @returns {object} Headers for the request
|
| 154 |
+
*/
|
| 155 |
+
function getFeatherlessHeaders(directories) {
|
| 156 |
+
const apiKey = readSecret(directories, SECRET_KEYS.FEATHERLESS);
|
| 157 |
+
const baseHeaders = { ...FEATHERLESS_HEADERS };
|
| 158 |
+
|
| 159 |
+
return apiKey ? Object.assign(baseHeaders, { 'Authorization': `Bearer ${apiKey}` }) : baseHeaders;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
/**
|
| 163 |
+
* Gets the headers for the HuggingFace API.
|
| 164 |
+
* @param {import('./users.js').UserDirectoryList} directories
|
| 165 |
+
* @returns {object} Headers for the request
|
| 166 |
+
*/
|
| 167 |
+
function getHuggingFaceHeaders(directories) {
|
| 168 |
+
const apiKey = readSecret(directories, SECRET_KEYS.HUGGINGFACE);
|
| 169 |
+
|
| 170 |
+
return apiKey ? ({
|
| 171 |
+
'Authorization': `Bearer ${apiKey}`,
|
| 172 |
+
}) : {};
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
/**
|
| 176 |
+
* Gets the headers for the Generic text completion API.
|
| 177 |
+
* @param {import('./users.js').UserDirectoryList} directories
|
| 178 |
+
* @returns {object} Headers for the request
|
| 179 |
+
*/
|
| 180 |
+
function getGenericHeaders(directories) {
|
| 181 |
+
const apiKey = readSecret(directories, SECRET_KEYS.GENERIC);
|
| 182 |
+
|
| 183 |
+
return apiKey ? ({
|
| 184 |
+
'Authorization': `Bearer ${apiKey}`,
|
| 185 |
+
}) : {};
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
export function getOverrideHeaders(urlHost) {
|
| 189 |
+
const requestOverrides = getConfigValue('requestOverrides', []);
|
| 190 |
+
const overrideHeaders = requestOverrides?.find((e) => e.hosts?.includes(urlHost))?.headers;
|
| 191 |
+
if (overrideHeaders && urlHost) {
|
| 192 |
+
return overrideHeaders;
|
| 193 |
+
} else {
|
| 194 |
+
return {};
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
/**
|
| 199 |
+
* Sets additional headers for the request.
|
| 200 |
+
* @param {import('express').Request} request Original request body
|
| 201 |
+
* @param {object} args New request arguments
|
| 202 |
+
* @param {string|null} server API server for new request
|
| 203 |
+
*/
|
| 204 |
+
export function setAdditionalHeaders(request, args, server) {
|
| 205 |
+
setAdditionalHeadersByType(args.headers, request.body.api_type, server, request.user.directories);
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
/**
|
| 209 |
+
*
|
| 210 |
+
* @param {object} requestHeaders Request headers
|
| 211 |
+
* @param {string} type API type
|
| 212 |
+
* @param {string|null} server API server for new request
|
| 213 |
+
* @param {import('./users.js').UserDirectoryList} directories User directories
|
| 214 |
+
*/
|
| 215 |
+
export function setAdditionalHeadersByType(requestHeaders, type, server, directories) {
|
| 216 |
+
const headerGetters = {
|
| 217 |
+
[TEXTGEN_TYPES.MANCER]: getMancerHeaders,
|
| 218 |
+
[TEXTGEN_TYPES.VLLM]: getVllmHeaders,
|
| 219 |
+
[TEXTGEN_TYPES.APHRODITE]: getAphroditeHeaders,
|
| 220 |
+
[TEXTGEN_TYPES.TABBY]: getTabbyHeaders,
|
| 221 |
+
[TEXTGEN_TYPES.TOGETHERAI]: getTogetherAIHeaders,
|
| 222 |
+
[TEXTGEN_TYPES.OOBA]: getOobaHeaders,
|
| 223 |
+
[TEXTGEN_TYPES.INFERMATICAI]: getInfermaticAIHeaders,
|
| 224 |
+
[TEXTGEN_TYPES.DREAMGEN]: getDreamGenHeaders,
|
| 225 |
+
[TEXTGEN_TYPES.OPENROUTER]: getOpenRouterHeaders,
|
| 226 |
+
[TEXTGEN_TYPES.KOBOLDCPP]: getKoboldCppHeaders,
|
| 227 |
+
[TEXTGEN_TYPES.LLAMACPP]: getLlamaCppHeaders,
|
| 228 |
+
[TEXTGEN_TYPES.FEATHERLESS]: getFeatherlessHeaders,
|
| 229 |
+
[TEXTGEN_TYPES.HUGGINGFACE]: getHuggingFaceHeaders,
|
| 230 |
+
[TEXTGEN_TYPES.GENERIC]: getGenericHeaders,
|
| 231 |
+
};
|
| 232 |
+
|
| 233 |
+
const getHeaders = headerGetters[type];
|
| 234 |
+
const headers = getHeaders ? getHeaders(directories) : {};
|
| 235 |
+
|
| 236 |
+
if (typeof server === 'string' && server.length > 0) {
|
| 237 |
+
try {
|
| 238 |
+
const url = new URL(server);
|
| 239 |
+
const overrideHeaders = getOverrideHeaders(url.host);
|
| 240 |
+
|
| 241 |
+
if (overrideHeaders && Object.keys(overrideHeaders).length > 0) {
|
| 242 |
+
Object.assign(headers, overrideHeaders);
|
| 243 |
+
}
|
| 244 |
+
} catch {
|
| 245 |
+
// Do nothing
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
Object.assign(requestHeaders, headers);
|
| 250 |
+
}
|
src/byaf.js
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { promises as fsPromises } from 'node:fs';
|
| 2 |
+
import path from 'node:path';
|
| 3 |
+
import urlJoin from 'url-join';
|
| 4 |
+
import { DEFAULT_AVATAR_PATH } from './constants.js';
|
| 5 |
+
import { extractFileFromZipBuffer } from './util.js';
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* A parser for BYAF (Backyard Archive Format) files.
|
| 9 |
+
*/
|
| 10 |
+
export class ByafParser {
|
| 11 |
+
/**
|
| 12 |
+
* @param {ArrayBufferLike} data BYAF ZIP buffer
|
| 13 |
+
*/
|
| 14 |
+
#data;
|
| 15 |
+
|
| 16 |
+
/**
|
| 17 |
+
* Creates an instance of ByafParser.
|
| 18 |
+
* @param {ArrayBufferLike} data BYAF ZIP buffer
|
| 19 |
+
*/
|
| 20 |
+
constructor(data) {
|
| 21 |
+
this.#data = data;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/**
|
| 25 |
+
* Replaces known macros in a string.
|
| 26 |
+
* @param {string} [str] String to process
|
| 27 |
+
* @returns {string} String with macros replaced
|
| 28 |
+
* @private
|
| 29 |
+
*/
|
| 30 |
+
static replaceMacros(str) {
|
| 31 |
+
return String(str || '')
|
| 32 |
+
.replace(/#{user}:/gi, '{{user}}:')
|
| 33 |
+
.replace(/#{character}:/gi, '{{char}}:')
|
| 34 |
+
.replace(/{character}(?!})/gi, '{{char}}')
|
| 35 |
+
.replace(/{user}(?!})/gi, '{{user}}');
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/**
|
| 39 |
+
* Formats example messages for a character.
|
| 40 |
+
* @param {ByafExampleMessage[]} [examples] Array of example objects
|
| 41 |
+
* @returns {string} Formatted example messages
|
| 42 |
+
* @private
|
| 43 |
+
*/
|
| 44 |
+
static formatExampleMessages(examples) {
|
| 45 |
+
if (!Array.isArray(examples)) {
|
| 46 |
+
return '';
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
let formattedExamples = '';
|
| 50 |
+
|
| 51 |
+
examples.forEach((example) => {
|
| 52 |
+
if (!example?.text) {
|
| 53 |
+
return;
|
| 54 |
+
}
|
| 55 |
+
formattedExamples += `<START>\n${ByafParser.replaceMacros(example.text)}\n`;
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
return formattedExamples.trimEnd();
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/**
|
| 62 |
+
* Formats alternate greetings for a character.
|
| 63 |
+
* @param {Partial<ByafScenario>[]} [scenarios] Array of scenario objects
|
| 64 |
+
* @returns {string[]} Formatted alternate greetings
|
| 65 |
+
* @private
|
| 66 |
+
*/
|
| 67 |
+
formatAlternateGreetings(scenarios) {
|
| 68 |
+
if (!Array.isArray(scenarios)) {
|
| 69 |
+
return [];
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// Skip one because it goes into 'first_mes'
|
| 73 |
+
if (scenarios.length <= 1) {
|
| 74 |
+
return [];
|
| 75 |
+
}
|
| 76 |
+
const greetings = new Set();
|
| 77 |
+
const firstScenarioFirstMessage = scenarios?.[0]?.firstMessages?.[0]?.text;
|
| 78 |
+
for (const scenario of scenarios.slice(1).filter(s => Array.isArray(s.firstMessages) && s.firstMessages.length > 0)) {
|
| 79 |
+
// As per the BYAF spec, "firstMessages" array MUST contain AT MOST one message.
|
| 80 |
+
// So we only consider the first one if it exists.
|
| 81 |
+
const firstMessage = scenario?.firstMessages?.[0];
|
| 82 |
+
if (firstMessage?.text && firstMessage.text !== firstScenarioFirstMessage) {
|
| 83 |
+
greetings.add(ByafParser.replaceMacros(firstMessage.text));
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
return Array.from(greetings);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/**
|
| 90 |
+
* Converts character book items to a structured format.
|
| 91 |
+
* @param {ByafLoreItem[]} items Array of key-value pairs
|
| 92 |
+
* @returns {CharacterBook|undefined} Converted character book or undefined if invalid
|
| 93 |
+
* @private
|
| 94 |
+
*/
|
| 95 |
+
convertCharacterBook(items) {
|
| 96 |
+
if (!Array.isArray(items) || items.length === 0) {
|
| 97 |
+
return undefined;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/** @type {CharacterBook} */
|
| 101 |
+
const book = {
|
| 102 |
+
entries: [],
|
| 103 |
+
extensions: {},
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
items.forEach((item, index) => {
|
| 107 |
+
if (!item) {
|
| 108 |
+
return;
|
| 109 |
+
}
|
| 110 |
+
book.entries.push({
|
| 111 |
+
keys: ByafParser.replaceMacros(item?.key).split(',').map(key => key.trim()).filter(Boolean),
|
| 112 |
+
content: ByafParser.replaceMacros(item?.value),
|
| 113 |
+
extensions: {},
|
| 114 |
+
enabled: true,
|
| 115 |
+
insertion_order: index,
|
| 116 |
+
});
|
| 117 |
+
});
|
| 118 |
+
|
| 119 |
+
return book;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
/**
|
| 123 |
+
* Extracts a character object from BYAF buffer.
|
| 124 |
+
* @param {ByafManifest} manifest BYAF manifest
|
| 125 |
+
* @returns {Promise<{character:ByafCharacter,characterPath:string}>} Character object
|
| 126 |
+
* @private
|
| 127 |
+
*/
|
| 128 |
+
async getCharacterFromManifest(manifest) {
|
| 129 |
+
const charactersArray = manifest?.characters;
|
| 130 |
+
|
| 131 |
+
if (!Array.isArray(charactersArray)) {
|
| 132 |
+
throw new Error('Invalid BYAF file: missing characters array');
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
if (charactersArray.length === 0) {
|
| 136 |
+
throw new Error('Invalid BYAF file: characters array is empty');
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
if (charactersArray.length > 1) {
|
| 140 |
+
console.warn('Warning: BYAF manifest contains more than one character, only the first one will be imported');
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
const characterPath = charactersArray[0];
|
| 144 |
+
if (!characterPath) {
|
| 145 |
+
throw new Error('Invalid BYAF file: missing character path');
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
const characterBuffer = await extractFileFromZipBuffer(this.#data, characterPath);
|
| 149 |
+
if (!characterBuffer) {
|
| 150 |
+
throw new Error('Invalid BYAF file: failed to extract character JSON');
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
try {
|
| 154 |
+
const character = JSON.parse(characterBuffer.toString());
|
| 155 |
+
return { character, characterPath };
|
| 156 |
+
} catch (error) {
|
| 157 |
+
console.error('Failed to parse character JSON from BYAF:', error);
|
| 158 |
+
throw new Error('Invalid BYAF file: character is not a valid JSON');
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
/**
|
| 163 |
+
* Extracts all scenario objects from BYAF buffer.
|
| 164 |
+
* @param {ByafManifest} manifest BYAF manifest
|
| 165 |
+
* @returns {Promise<Partial<ByafScenario>[]>} Scenarios array
|
| 166 |
+
* @private
|
| 167 |
+
*/
|
| 168 |
+
async getScenariosFromManifest(manifest) {
|
| 169 |
+
const scenariosArray = manifest?.scenarios;
|
| 170 |
+
|
| 171 |
+
if (!Array.isArray(scenariosArray) || scenariosArray.length === 0) {
|
| 172 |
+
console.warn('Warning: BYAF manifest contains no scenarios');
|
| 173 |
+
return [{}];
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
const scenarios = [];
|
| 177 |
+
|
| 178 |
+
for (const scenarioPath of scenariosArray) {
|
| 179 |
+
const scenarioBuffer = await extractFileFromZipBuffer(this.#data, scenarioPath);
|
| 180 |
+
if (!scenarioBuffer) {
|
| 181 |
+
console.warn('Warning: failed to extract BYAF scenario JSON');
|
| 182 |
+
}
|
| 183 |
+
if (scenarioBuffer) {
|
| 184 |
+
try {
|
| 185 |
+
scenarios.push(JSON.parse(scenarioBuffer.toString()));
|
| 186 |
+
} catch (error) {
|
| 187 |
+
console.warn('Warning: BYAF scenario is not a valid JSON', error);
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
if (scenarios.length === 0) {
|
| 193 |
+
console.warn('Warning: BYAF manifest contains no valid scenarios');
|
| 194 |
+
return [{}];
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
return scenarios;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
/**
|
| 201 |
+
* Extracts all character icon images from BYAF buffer.
|
| 202 |
+
* @param {ByafCharacter} character Character object
|
| 203 |
+
* @param {string} characterPath Path to the character in the BYAF manifest
|
| 204 |
+
* @return {Promise<{filename: string, image: Buffer, label: string}[]>} Image buffer
|
| 205 |
+
* @private
|
| 206 |
+
*/
|
| 207 |
+
async getCharacterImages(character, characterPath) {
|
| 208 |
+
const defaultAvatarBuffer = await fsPromises.readFile(DEFAULT_AVATAR_PATH);
|
| 209 |
+
const characterImages = character?.images;
|
| 210 |
+
|
| 211 |
+
if (!Array.isArray(characterImages) || characterImages.length === 0) {
|
| 212 |
+
console.warn('Warning: BYAF character has no images');
|
| 213 |
+
return [{ filename: '', image: defaultAvatarBuffer, label: '' }];
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
const imageBuffers = [];
|
| 217 |
+
for (const image of characterImages) {
|
| 218 |
+
const imagePath = image?.path;
|
| 219 |
+
if (!imagePath) {
|
| 220 |
+
console.warn('Warning: BYAF character image path is empty');
|
| 221 |
+
continue;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
const fullImagePath = urlJoin(path.dirname(characterPath), imagePath);
|
| 225 |
+
const imageBuffer = await extractFileFromZipBuffer(this.#data, fullImagePath);
|
| 226 |
+
if (!imageBuffer) {
|
| 227 |
+
console.warn('Warning: failed to extract BYAF character image');
|
| 228 |
+
continue;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
imageBuffers.push({ filename: path.basename(imagePath), image: imageBuffer, label: image?.label || '' });
|
| 232 |
+
}
|
| 233 |
+
if (imageBuffers.length === 0) {
|
| 234 |
+
console.warn('Warning: BYAF character has no valid images');
|
| 235 |
+
return [{ filename: '', image: defaultAvatarBuffer, label: '' }];
|
| 236 |
+
}
|
| 237 |
+
return imageBuffers;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
/**
|
| 241 |
+
* Formats BYAF data as a character card.
|
| 242 |
+
* @param {ByafManifest} manifest BYAF manifest
|
| 243 |
+
* @param {ByafCharacter} character Character object
|
| 244 |
+
* @param {Partial<ByafScenario>[]} scenarios Scenarios array
|
| 245 |
+
* @return {TavernCardV2} Character card object
|
| 246 |
+
* @private
|
| 247 |
+
*/
|
| 248 |
+
getCharacterCard(manifest, character, scenarios) {
|
| 249 |
+
return {
|
| 250 |
+
spec: 'chara_card_v2',
|
| 251 |
+
spec_version: '2.0',
|
| 252 |
+
data: {
|
| 253 |
+
name: character?.name || character?.displayName || '',
|
| 254 |
+
description: ByafParser.replaceMacros(character?.persona),
|
| 255 |
+
personality: '',
|
| 256 |
+
scenario: ByafParser.replaceMacros(scenarios[0]?.narrative),
|
| 257 |
+
first_mes: ByafParser.replaceMacros(scenarios[0]?.firstMessages?.[0]?.text),
|
| 258 |
+
mes_example: ByafParser.formatExampleMessages(scenarios[0]?.exampleMessages),
|
| 259 |
+
creator_notes: manifest?.author?.backyardURL || '', // To preserve the link to the author from BYAF manifest, this is a good place.
|
| 260 |
+
system_prompt: ByafParser.replaceMacros(scenarios[0]?.formattingInstructions),
|
| 261 |
+
post_history_instructions: '',
|
| 262 |
+
alternate_greetings: this.formatAlternateGreetings(scenarios),
|
| 263 |
+
character_book: this.convertCharacterBook(character?.loreItems),
|
| 264 |
+
tags: character?.isNSFW ? ['nsfw'] : [], // Since there are no tags in BYAF spec, we can use this to preserve the isNSFW flag.
|
| 265 |
+
creator: manifest?.author?.name || '',
|
| 266 |
+
character_version: '',
|
| 267 |
+
extensions: { ...(character?.displayName && { 'display_name': character?.displayName }) }, // Preserve display name unmodified using extensions. "display_name" is not used by TavernIntern currently.
|
| 268 |
+
},
|
| 269 |
+
// @ts-ignore Non-standard spec extension
|
| 270 |
+
create_date: new Date().toISOString(),
|
| 271 |
+
};
|
| 272 |
+
}
|
| 273 |
+
/**
|
| 274 |
+
* Gets chat backgrounds from BYAF data mapped to their respective scenarios.
|
| 275 |
+
* @param {ByafCharacter} character Character object
|
| 276 |
+
* @param {Partial<ByafScenario>[]} scenarios Scenarios array
|
| 277 |
+
* @returns {Promise<Array<ByafChatBackground>>} Chat backgrounds
|
| 278 |
+
* @private
|
| 279 |
+
*/
|
| 280 |
+
async getChatBackgrounds(character, scenarios) {
|
| 281 |
+
// Implementation for extracting chat backgrounds from BYAF data
|
| 282 |
+
const backgrounds = [];
|
| 283 |
+
let i = 1;
|
| 284 |
+
for (const scenario of scenarios) {
|
| 285 |
+
const bgImagePath = scenario?.backgroundImage;
|
| 286 |
+
if (bgImagePath) {
|
| 287 |
+
const data = await extractFileFromZipBuffer(this.#data, bgImagePath);
|
| 288 |
+
if (data) {
|
| 289 |
+
const existingIndex = backgrounds.findIndex(bg => bg.data.compare(data) === 0);
|
| 290 |
+
if (existingIndex !== -1) {
|
| 291 |
+
backgrounds[existingIndex].paths.push(bgImagePath);
|
| 292 |
+
continue; // Skip adding a new background since it already exists
|
| 293 |
+
}
|
| 294 |
+
backgrounds.push({
|
| 295 |
+
name: `${character?.name} bg ${i++}` || '',
|
| 296 |
+
data: data,
|
| 297 |
+
paths: [bgImagePath],
|
| 298 |
+
});
|
| 299 |
+
}
|
| 300 |
+
}
|
| 301 |
+
}
|
| 302 |
+
return backgrounds;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
/**
|
| 306 |
+
* Gets the manifest from the BYAF data.
|
| 307 |
+
* @returns {Promise<ByafManifest>} Parsed manifest
|
| 308 |
+
* @private
|
| 309 |
+
*/
|
| 310 |
+
async getManifest() {
|
| 311 |
+
const manifestBuffer = await extractFileFromZipBuffer(this.#data, 'manifest.json');
|
| 312 |
+
if (!manifestBuffer) {
|
| 313 |
+
throw new Error('Failed to extract manifest.json from BYAF file');
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
const manifest = JSON.parse(manifestBuffer.toString());
|
| 317 |
+
if (!manifest || typeof manifest !== 'object') {
|
| 318 |
+
throw new Error('Invalid BYAF manifest');
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
return manifest;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
/**
|
| 325 |
+
* Imports a chat from BYAF format.
|
| 326 |
+
* @param {Partial<ByafScenario>} scenario Scenario object
|
| 327 |
+
* @param {string} userName User name
|
| 328 |
+
* @param {string} characterName Character name
|
| 329 |
+
* @param {Array<ByafChatBackground>} chatBackgrounds Chat backgrounds
|
| 330 |
+
* @returns {string} Chat data
|
| 331 |
+
*/
|
| 332 |
+
static getChatFromScenario(scenario, userName, characterName, chatBackgrounds) {
|
| 333 |
+
const chatStartDate = scenario?.messages?.length == 0 ? new Date().toISOString() : scenario?.messages?.filter(m => 'createdAt' in m)[0].createdAt;
|
| 334 |
+
const chatBackground = chatBackgrounds.find(bg => bg.paths.includes(scenario?.backgroundImage || ''))?.name || '';
|
| 335 |
+
/** @type {object[]} */
|
| 336 |
+
const chat = [{
|
| 337 |
+
user_name: 'unused',
|
| 338 |
+
character_name: 'unused',
|
| 339 |
+
chat_metadata: {
|
| 340 |
+
scenario: scenario?.narrative ?? '',
|
| 341 |
+
mes_example: ByafParser.formatExampleMessages(scenario?.exampleMessages),
|
| 342 |
+
system_prompt: ByafParser.replaceMacros(scenario?.formattingInstructions),
|
| 343 |
+
mes_examples_optional: scenario?.canDeleteExampleMessages ?? false,
|
| 344 |
+
byaf_model_settings: {
|
| 345 |
+
model: scenario?.model ?? '',
|
| 346 |
+
temperature: scenario?.temperature ?? 1.2,
|
| 347 |
+
top_k: scenario?.topK ?? 40,
|
| 348 |
+
top_p: scenario?.topP ?? 0.9,
|
| 349 |
+
min_p: scenario?.minP ?? 0.1,
|
| 350 |
+
min_p_enabled: scenario?.minPEnabled ?? true,
|
| 351 |
+
repeat_penalty: scenario?.repeatPenalty ?? 1.05,
|
| 352 |
+
repeat_penalty_tokens: scenario?.repeatLastN ?? 256,
|
| 353 |
+
by_prompt_template: scenario?.promptTemplate ?? 'general',
|
| 354 |
+
grammar: scenario?.grammar ?? null,
|
| 355 |
+
},
|
| 356 |
+
chat_backgrounds: chatBackground ? [chatBackground] : [],
|
| 357 |
+
custom_background: chatBackground ? `url("${encodeURI(chatBackground)}")` : '',
|
| 358 |
+
},
|
| 359 |
+
}];
|
| 360 |
+
// Add the first message IF it exists.
|
| 361 |
+
if (scenario?.firstMessages?.length && scenario?.firstMessages?.length > 0 && scenario?.firstMessages?.[0]?.text) {
|
| 362 |
+
chat.push({
|
| 363 |
+
name: characterName,
|
| 364 |
+
is_user: false,
|
| 365 |
+
send_date: chatStartDate,
|
| 366 |
+
mes: scenario?.firstMessages?.[0]?.text || '',
|
| 367 |
+
});
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
const sortByTimestamp = (newest, curr) => {
|
| 371 |
+
const aTime = new Date(newest.activeTimestamp);
|
| 372 |
+
const bTime = new Date(curr.activeTimestamp);
|
| 373 |
+
return aTime >= bTime ? newest : curr;
|
| 374 |
+
};
|
| 375 |
+
|
| 376 |
+
const getNewestAiMessage = (message) => {
|
| 377 |
+
return message.outputs.reduce(sortByTimestamp);
|
| 378 |
+
};
|
| 379 |
+
const getSwipesForAiMessage = (aiMessage) => {
|
| 380 |
+
return aiMessage.outputs.map(output => output.text);
|
| 381 |
+
};
|
| 382 |
+
|
| 383 |
+
const userMessages = scenario?.messages?.filter(msg => msg.type === 'human');
|
| 384 |
+
const characterMessages = scenario?.messages?.filter(msg => msg.type === 'ai');
|
| 385 |
+
/**
|
| 386 |
+
* Reorders messages by interleaving user and character messages so that they are in correct chronological order.
|
| 387 |
+
* This is only needed to import old chats from Backyard AI that were incorrectly imported by an earlier version
|
| 388 |
+
* that completely messed up the order of messages. Backyard AI Windows frontend never supported creation of chats
|
| 389 |
+
* with which were ordered like this in the first place, so for most users this is desired functionality.
|
| 390 |
+
*/
|
| 391 |
+
if (userMessages && characterMessages && userMessages.length === characterMessages.length) { // Only do the reordering if there are equal numbers of user and character messages, otherwise just import in existing order, because it's probably correct already.
|
| 392 |
+
for (let i = 0; i < userMessages.length; i++) {
|
| 393 |
+
chat.push({
|
| 394 |
+
name: userName,
|
| 395 |
+
is_user: true,
|
| 396 |
+
send_date: Number(userMessages[i]?.createdAt),
|
| 397 |
+
mes: userMessages[i]?.text,
|
| 398 |
+
});
|
| 399 |
+
const aiMessage = getNewestAiMessage(characterMessages[i]);
|
| 400 |
+
const aiSwipes = getSwipesForAiMessage(characterMessages[i]);
|
| 401 |
+
chat.push({
|
| 402 |
+
name: characterName,
|
| 403 |
+
is_user: false,
|
| 404 |
+
send_date: Number(aiMessage.createdAt),
|
| 405 |
+
mes: aiMessage.text,
|
| 406 |
+
swipes: aiSwipes,
|
| 407 |
+
swipe_id: aiSwipes.findIndex(s => s === aiMessage.text),
|
| 408 |
+
});
|
| 409 |
+
}
|
| 410 |
+
} else if (scenario?.messages) {
|
| 411 |
+
for (const message of scenario.messages) {
|
| 412 |
+
const isUser = message.type === 'human';
|
| 413 |
+
const aiMessage = !isUser ? getNewestAiMessage(message) : null;
|
| 414 |
+
const chatMessage = {
|
| 415 |
+
name: isUser ? userName : characterName,
|
| 416 |
+
is_user: isUser,
|
| 417 |
+
send_date: Number(isUser ? message.createdAt : aiMessage.createdAt),
|
| 418 |
+
mes: isUser ? message.text : aiMessage.text,
|
| 419 |
+
};
|
| 420 |
+
if (!isUser) {
|
| 421 |
+
const aiSwipes = getSwipesForAiMessage(message);
|
| 422 |
+
chatMessage.swipes = aiSwipes;
|
| 423 |
+
chatMessage.swipe_id = aiSwipes.findIndex(s => s === aiMessage.text);
|
| 424 |
+
}
|
| 425 |
+
chat.push(chatMessage);
|
| 426 |
+
}
|
| 427 |
+
} else {
|
| 428 |
+
console.warn('Warning: BYAF scenario contained no messages property.');
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
return chat.map(obj => JSON.stringify(obj)).join('\n');
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
/**
|
| 435 |
+
* Parses the BYAF data.
|
| 436 |
+
* @return {Promise<ByafParseResult>} Parsed character card and image buffer
|
| 437 |
+
*/
|
| 438 |
+
async parse() {
|
| 439 |
+
const manifest = await this.getManifest();
|
| 440 |
+
const { character, characterPath } = await this.getCharacterFromManifest(manifest);
|
| 441 |
+
const scenarios = await this.getScenariosFromManifest(manifest);
|
| 442 |
+
const images = await this.getCharacterImages(character, characterPath);
|
| 443 |
+
const card = this.getCharacterCard(manifest, character, scenarios);
|
| 444 |
+
const chatBackgrounds = await this.getChatBackgrounds(character, scenarios);
|
| 445 |
+
return { card, images, scenarios, chatBackgrounds, character };
|
| 446 |
+
}
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
export default ByafParser;
|
src/character-card-parser.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from 'node:fs';
|
| 2 |
+
import { Buffer } from 'node:buffer';
|
| 3 |
+
|
| 4 |
+
import encode from './png/encode.js';
|
| 5 |
+
import extract from 'png-chunks-extract';
|
| 6 |
+
import PNGtext from 'png-chunk-text';
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Writes Character metadata to a PNG image buffer.
|
| 10 |
+
* Writes only 'chara', 'ccv3' is not supported and removed not to create a mismatch.
|
| 11 |
+
* @param {Buffer} image PNG image buffer
|
| 12 |
+
* @param {string} data Character data to write
|
| 13 |
+
* @returns {Buffer} PNG image buffer with metadata
|
| 14 |
+
*/
|
| 15 |
+
export const write = (image, data) => {
|
| 16 |
+
const chunks = extract(new Uint8Array(image));
|
| 17 |
+
const tEXtChunks = chunks.filter(chunk => chunk.name === 'tEXt');
|
| 18 |
+
|
| 19 |
+
// Remove existing tEXt chunks
|
| 20 |
+
for (const tEXtChunk of tEXtChunks) {
|
| 21 |
+
const data = PNGtext.decode(tEXtChunk.data);
|
| 22 |
+
if (data.keyword.toLowerCase() === 'chara' || data.keyword.toLowerCase() === 'ccv3') {
|
| 23 |
+
chunks.splice(chunks.indexOf(tEXtChunk), 1);
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// Add new v2 chunk before the IEND chunk
|
| 28 |
+
const base64EncodedData = Buffer.from(data, 'utf8').toString('base64');
|
| 29 |
+
chunks.splice(-1, 0, PNGtext.encode('chara', base64EncodedData));
|
| 30 |
+
|
| 31 |
+
// Try adding v3 chunk before the IEND chunk
|
| 32 |
+
try {
|
| 33 |
+
//change v2 format to v3
|
| 34 |
+
const v3Data = JSON.parse(data);
|
| 35 |
+
v3Data.spec = 'chara_card_v3';
|
| 36 |
+
v3Data.spec_version = '3.0';
|
| 37 |
+
|
| 38 |
+
const base64EncodedData = Buffer.from(JSON.stringify(v3Data), 'utf8').toString('base64');
|
| 39 |
+
chunks.splice(-1, 0, PNGtext.encode('ccv3', base64EncodedData));
|
| 40 |
+
} catch (error) {
|
| 41 |
+
// Ignore errors when adding v3 chunk
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
const newBuffer = Buffer.from(encode(chunks));
|
| 45 |
+
return newBuffer;
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
/**
|
| 49 |
+
* Reads Character metadata from a PNG image buffer.
|
| 50 |
+
* Supports both V2 (chara) and V3 (ccv3). V3 (ccv3) takes precedence.
|
| 51 |
+
* @param {Buffer} image PNG image buffer
|
| 52 |
+
* @returns {string} Character data
|
| 53 |
+
*/
|
| 54 |
+
export const read = (image) => {
|
| 55 |
+
const chunks = extract(new Uint8Array(image));
|
| 56 |
+
|
| 57 |
+
const textChunks = chunks.filter((chunk) => chunk.name === 'tEXt').map((chunk) => PNGtext.decode(chunk.data));
|
| 58 |
+
|
| 59 |
+
if (textChunks.length === 0) {
|
| 60 |
+
console.error('PNG metadata does not contain any text chunks.');
|
| 61 |
+
throw new Error('No PNG metadata.');
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
const ccv3Index = textChunks.findIndex((chunk) => chunk.keyword.toLowerCase() === 'ccv3');
|
| 65 |
+
|
| 66 |
+
if (ccv3Index > -1) {
|
| 67 |
+
return Buffer.from(textChunks[ccv3Index].text, 'base64').toString('utf8');
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
const charaIndex = textChunks.findIndex((chunk) => chunk.keyword.toLowerCase() === 'chara');
|
| 71 |
+
|
| 72 |
+
if (charaIndex > -1) {
|
| 73 |
+
return Buffer.from(textChunks[charaIndex].text, 'base64').toString('utf8');
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
console.error('PNG metadata does not contain any character data.');
|
| 77 |
+
throw new Error('No PNG metadata.');
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
/**
|
| 81 |
+
* Parses a card image and returns the character metadata.
|
| 82 |
+
* @param {string} cardUrl Path to the card image
|
| 83 |
+
* @param {string} format File format
|
| 84 |
+
* @returns {Promise<string>} Character data
|
| 85 |
+
*/
|
| 86 |
+
export const parse = async (cardUrl, format) => {
|
| 87 |
+
let fileFormat = format === undefined ? 'png' : format;
|
| 88 |
+
|
| 89 |
+
switch (fileFormat) {
|
| 90 |
+
case 'png': {
|
| 91 |
+
const buffer = fs.readFileSync(cardUrl);
|
| 92 |
+
return read(buffer);
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
throw new Error('Unsupported format');
|
| 97 |
+
};
|
| 98 |
+
|
src/charx.js
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from 'node:fs';
|
| 2 |
+
import path from 'node:path';
|
| 3 |
+
import _ from 'lodash';
|
| 4 |
+
import sanitize from 'sanitize-filename';
|
| 5 |
+
import { sync as writeFileAtomicSync } from 'write-file-atomic';
|
| 6 |
+
import { extractFileFromZipBuffer, extractFilesFromZipBuffer, normalizeZipEntryPath, ensureDirectory } from './util.js';
|
| 7 |
+
import { DEFAULT_AVATAR_PATH } from './constants.js';
|
| 8 |
+
|
| 9 |
+
// 'embeded://' is intentional - RisuAI exports use this misspelling
|
| 10 |
+
const CHARX_EMBEDDED_URI_PREFIXES = ['embeded://', 'embedded://', '__asset:'];
|
| 11 |
+
const CHARX_IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'webp', 'gif', 'apng', 'avif', 'bmp', 'jfif']);
|
| 12 |
+
const CHARX_SPRITE_TYPES = new Set(['emotion', 'expression']);
|
| 13 |
+
const CHARX_BACKGROUND_TYPES = new Set(['background']);
|
| 14 |
+
|
| 15 |
+
// ZIP local file header signature: PK\x03\x04
|
| 16 |
+
const ZIP_SIGNATURE = Buffer.from([0x50, 0x4B, 0x03, 0x04]);
|
| 17 |
+
|
| 18 |
+
/**
|
| 19 |
+
* Find ZIP data start in buffer (handles SFX/self-extracting archives).
|
| 20 |
+
* @param {Buffer} buffer
|
| 21 |
+
* @returns {Buffer} Buffer starting at ZIP signature, or original if not found
|
| 22 |
+
*/
|
| 23 |
+
function findZipStart(buffer) {
|
| 24 |
+
const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
| 25 |
+
const index = buf.indexOf(ZIP_SIGNATURE);
|
| 26 |
+
if (index > 0) {
|
| 27 |
+
return buf.slice(index);
|
| 28 |
+
}
|
| 29 |
+
return buf;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* @typedef {Object} CharXAsset
|
| 34 |
+
* @property {string} type - Asset type (emotion, expression, background, etc.)
|
| 35 |
+
* @property {string} name - Asset name from metadata
|
| 36 |
+
* @property {string} ext - File extension (lowercase, no dot)
|
| 37 |
+
* @property {string} zipPath - Normalized path within the ZIP archive
|
| 38 |
+
* @property {number} order - Original index in assets array
|
| 39 |
+
* @property {string} [storageCategory] - 'sprite' | 'background' | 'misc' (set by mapCharXAssetsForStorage)
|
| 40 |
+
* @property {string} [baseName] - Normalized filename base (set by mapCharXAssetsForStorage)
|
| 41 |
+
*/
|
| 42 |
+
|
| 43 |
+
/**
|
| 44 |
+
* @typedef {Object} CharXParseResult
|
| 45 |
+
* @property {Object} card - Parsed card.json (CCv2 or CCv3 spec)
|
| 46 |
+
* @property {string|Buffer} avatar - Avatar image buffer or DEFAULT_AVATAR_PATH
|
| 47 |
+
* @property {CharXAsset[]} auxiliaryAssets - Assets mapped for storage
|
| 48 |
+
* @property {Map<string, Buffer>} extractedBuffers - Map of zipPath to extracted buffer
|
| 49 |
+
*/
|
| 50 |
+
|
| 51 |
+
export class CharXParser {
|
| 52 |
+
#data;
|
| 53 |
+
|
| 54 |
+
/**
|
| 55 |
+
* @param {ArrayBuffer|Buffer} data
|
| 56 |
+
*/
|
| 57 |
+
constructor(data) {
|
| 58 |
+
// Handle SFX (self-extracting) ZIP archives by finding the actual ZIP start
|
| 59 |
+
this.#data = findZipStart(Buffer.isBuffer(data) ? data : Buffer.from(data));
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/**
|
| 63 |
+
* Parse the CharX archive and extract card data and assets.
|
| 64 |
+
* @returns {Promise<CharXParseResult>}
|
| 65 |
+
*/
|
| 66 |
+
async parse() {
|
| 67 |
+
console.info('Importing from CharX');
|
| 68 |
+
const cardBuffer = await extractFileFromZipBuffer(this.#data, 'card.json');
|
| 69 |
+
|
| 70 |
+
if (!cardBuffer) {
|
| 71 |
+
throw new Error('Failed to extract card.json from CharX file');
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
const card = JSON.parse(cardBuffer.toString());
|
| 75 |
+
|
| 76 |
+
if (card.spec === undefined) {
|
| 77 |
+
throw new Error('Invalid CharX card file: missing spec field');
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
const embeddedAssets = this.collectCharXAssets(card);
|
| 81 |
+
const iconAsset = this.pickCharXIconAsset(embeddedAssets);
|
| 82 |
+
const auxiliaryAssets = this.mapCharXAssetsForStorage(embeddedAssets);
|
| 83 |
+
|
| 84 |
+
const archivePaths = new Set();
|
| 85 |
+
|
| 86 |
+
if (iconAsset?.zipPath) {
|
| 87 |
+
archivePaths.add(iconAsset.zipPath);
|
| 88 |
+
}
|
| 89 |
+
for (const asset of auxiliaryAssets) {
|
| 90 |
+
if (asset?.zipPath) {
|
| 91 |
+
archivePaths.add(asset.zipPath);
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
let extractedBuffers = new Map();
|
| 96 |
+
if (archivePaths.size > 0) {
|
| 97 |
+
extractedBuffers = await extractFilesFromZipBuffer(this.#data, [...archivePaths]);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/** @type {string|Buffer} */
|
| 101 |
+
let avatar = DEFAULT_AVATAR_PATH;
|
| 102 |
+
if (iconAsset?.zipPath) {
|
| 103 |
+
const iconBuffer = extractedBuffers.get(iconAsset.zipPath);
|
| 104 |
+
if (iconBuffer) {
|
| 105 |
+
avatar = iconBuffer;
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
return { card, avatar, auxiliaryAssets, extractedBuffers };
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
getEmbeddedZipPathFromUri(uri) {
|
| 113 |
+
if (typeof uri !== 'string') {
|
| 114 |
+
return null;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
const trimmed = uri.trim();
|
| 118 |
+
if (!trimmed) {
|
| 119 |
+
return null;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
const lower = trimmed.toLowerCase();
|
| 123 |
+
for (const prefix of CHARX_EMBEDDED_URI_PREFIXES) {
|
| 124 |
+
if (lower.startsWith(prefix)) {
|
| 125 |
+
const rawPath = trimmed.slice(prefix.length);
|
| 126 |
+
return normalizeZipEntryPath(rawPath);
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
return null;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
/**
|
| 134 |
+
* Normalize extension string: lowercase, strip leading dot.
|
| 135 |
+
* @param {string} ext
|
| 136 |
+
* @returns {string}
|
| 137 |
+
*/
|
| 138 |
+
normalizeExtString(ext) {
|
| 139 |
+
if (typeof ext !== 'string') return '';
|
| 140 |
+
return ext.trim().toLowerCase().replace(/^\./, '');
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
/**
|
| 144 |
+
* Strip trailing image extension from asset name if present.
|
| 145 |
+
* Handles cases like "image.png" with ext "png" → "image" (avoids "image.png.png")
|
| 146 |
+
* @param {string} name - Asset name that may contain extension
|
| 147 |
+
* @param {string} expectedExt - The expected extension (lowercase, no dot)
|
| 148 |
+
* @returns {string} Name with trailing extension stripped if it matched
|
| 149 |
+
*/
|
| 150 |
+
stripTrailingImageExtension(name, expectedExt) {
|
| 151 |
+
if (!name || !expectedExt) return name;
|
| 152 |
+
const lower = name.toLowerCase();
|
| 153 |
+
// Check if name ends with the expected extension
|
| 154 |
+
if (lower.endsWith(`.${expectedExt}`)) {
|
| 155 |
+
return name.slice(0, -(expectedExt.length + 1));
|
| 156 |
+
}
|
| 157 |
+
// Also check for any known image extension at the end
|
| 158 |
+
for (const ext of CHARX_IMAGE_EXTENSIONS) {
|
| 159 |
+
if (lower.endsWith(`.${ext}`)) {
|
| 160 |
+
return name.slice(0, -(ext.length + 1));
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
return name;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
deriveCharXAssetExtension(assetExt, zipPath) {
|
| 167 |
+
const metaExt = this.normalizeExtString(assetExt);
|
| 168 |
+
const pathExt = this.normalizeExtString(path.extname(zipPath || ''));
|
| 169 |
+
return metaExt || pathExt;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
collectCharXAssets(card) {
|
| 173 |
+
const assets = _.get(card, 'data.assets');
|
| 174 |
+
if (!Array.isArray(assets)) {
|
| 175 |
+
return [];
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
return assets.map((asset, index) => {
|
| 179 |
+
if (!asset) {
|
| 180 |
+
return null;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
const zipPath = this.getEmbeddedZipPathFromUri(asset.uri);
|
| 184 |
+
if (!zipPath) {
|
| 185 |
+
return null;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
const ext = this.deriveCharXAssetExtension(asset.ext, zipPath);
|
| 189 |
+
const type = typeof asset.type === 'string' ? asset.type.toLowerCase() : '';
|
| 190 |
+
const name = typeof asset.name === 'string' ? asset.name : '';
|
| 191 |
+
|
| 192 |
+
return {
|
| 193 |
+
type,
|
| 194 |
+
name,
|
| 195 |
+
ext,
|
| 196 |
+
zipPath,
|
| 197 |
+
order: index,
|
| 198 |
+
};
|
| 199 |
+
}).filter(Boolean);
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
pickCharXIconAsset(assets) {
|
| 203 |
+
const iconAssets = assets.filter(asset => asset.type === 'icon' && CHARX_IMAGE_EXTENSIONS.has(asset.ext) && asset.zipPath);
|
| 204 |
+
if (iconAssets.length === 0) {
|
| 205 |
+
return null;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
const mainIcon = iconAssets.find(asset => asset.name?.toLowerCase() === 'main');
|
| 209 |
+
return mainIcon || iconAssets[0];
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
/**
|
| 213 |
+
* Normalize asset name for filesystem storage.
|
| 214 |
+
* @param {string} name - Original asset name
|
| 215 |
+
* @param {string} fallback - Fallback name if normalization fails
|
| 216 |
+
* @param {boolean} useHyphens - Use hyphens instead of underscores (for sprites)
|
| 217 |
+
* @returns {string} Normalized filename base (without extension)
|
| 218 |
+
*/
|
| 219 |
+
getCharXAssetBaseName(name, fallback, useHyphens = false) {
|
| 220 |
+
const cleaned = (String(name ?? '').trim() || '');
|
| 221 |
+
if (!cleaned) {
|
| 222 |
+
return fallback.toLowerCase();
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
const separator = useHyphens ? '-' : '_';
|
| 226 |
+
// Convert to lowercase, collapse non-alphanumeric runs to separator, trim edges
|
| 227 |
+
const base = cleaned
|
| 228 |
+
.toLowerCase()
|
| 229 |
+
.replace(/[^a-z0-9]+/g, separator)
|
| 230 |
+
.replace(new RegExp(`^${separator}|${separator}$`, 'g'), '');
|
| 231 |
+
|
| 232 |
+
if (!base) {
|
| 233 |
+
return fallback.toLowerCase();
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
const sanitized = sanitize(base);
|
| 237 |
+
return (sanitized || fallback).toLowerCase();
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
mapCharXAssetsForStorage(assets) {
|
| 241 |
+
return assets.reduce((acc, asset) => {
|
| 242 |
+
if (!asset?.zipPath) {
|
| 243 |
+
return acc;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
const ext = (asset.ext || '').toLowerCase();
|
| 247 |
+
if (!CHARX_IMAGE_EXTENSIONS.has(ext)) {
|
| 248 |
+
return acc;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
if (asset.type === 'icon' || asset.type === 'user_icon') {
|
| 252 |
+
return acc;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
let storageCategory;
|
| 256 |
+
if (CHARX_SPRITE_TYPES.has(asset.type)) {
|
| 257 |
+
storageCategory = 'sprite';
|
| 258 |
+
} else if (CHARX_BACKGROUND_TYPES.has(asset.type)) {
|
| 259 |
+
storageCategory = 'background';
|
| 260 |
+
} else {
|
| 261 |
+
storageCategory = 'misc';
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
// Use hyphens for sprites so ST's expression label extraction works correctly
|
| 265 |
+
// (sprites.js extracts label via regex that splits on dash or dot)
|
| 266 |
+
const useHyphens = storageCategory === 'sprite';
|
| 267 |
+
// Strip trailing extension from name if present (e.g., "image.png" with ext "png")
|
| 268 |
+
const nameWithoutExt = this.stripTrailingImageExtension(asset.name, ext);
|
| 269 |
+
acc.push({
|
| 270 |
+
...asset,
|
| 271 |
+
ext,
|
| 272 |
+
storageCategory,
|
| 273 |
+
baseName: this.getCharXAssetBaseName(nameWithoutExt, `${storageCategory}-${asset.order ?? 0}`, useHyphens),
|
| 274 |
+
});
|
| 275 |
+
|
| 276 |
+
return acc;
|
| 277 |
+
}, []);
|
| 278 |
+
}
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
/**
|
| 282 |
+
* Delete existing file with same base name (any extension) before overwriting.
|
| 283 |
+
* Matches ST's sprite upload behavior in sprites.js.
|
| 284 |
+
* @param {string} dirPath - Directory path
|
| 285 |
+
* @param {string} baseName - Base filename without extension
|
| 286 |
+
*/
|
| 287 |
+
function deleteExistingByBaseName(dirPath, baseName) {
|
| 288 |
+
try {
|
| 289 |
+
const files = fs.readdirSync(dirPath, { withFileTypes: true }).filter(f => f.isFile()).map(f => f.name);
|
| 290 |
+
for (const file of files) {
|
| 291 |
+
if (path.parse(file).name === baseName) {
|
| 292 |
+
fs.unlinkSync(path.join(dirPath, file));
|
| 293 |
+
}
|
| 294 |
+
}
|
| 295 |
+
} catch {
|
| 296 |
+
// Directory doesn't exist yet or other error, that's fine
|
| 297 |
+
}
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
/**
|
| 301 |
+
* Persist extracted CharX assets to appropriate ST directories.
|
| 302 |
+
* Note: Uses sync writes consistent with ST's existing file handling.
|
| 303 |
+
* @param {Array} assets - Mapped assets from CharXParser
|
| 304 |
+
* @param {Map<string, Buffer>} bufferMap - Extracted file buffers
|
| 305 |
+
* @param {Object} directories - User directories object
|
| 306 |
+
* @param {string} characterFolder - Character folder name (sanitized)
|
| 307 |
+
* @returns {{sprites: number, backgrounds: number, misc: number}}
|
| 308 |
+
*/
|
| 309 |
+
export function persistCharXAssets(assets, bufferMap, directories, characterFolder) {
|
| 310 |
+
/** @type {{sprites: number, backgrounds: number, misc: number}} */
|
| 311 |
+
const summary = { sprites: 0, backgrounds: 0, misc: 0 };
|
| 312 |
+
if (!Array.isArray(assets) || assets.length === 0) {
|
| 313 |
+
return summary;
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
let spritesPath = null;
|
| 317 |
+
let miscPath = null;
|
| 318 |
+
|
| 319 |
+
const ensureSpritesPath = () => {
|
| 320 |
+
if (spritesPath) {
|
| 321 |
+
return spritesPath;
|
| 322 |
+
}
|
| 323 |
+
const candidate = path.join(directories.characters, characterFolder);
|
| 324 |
+
if (!ensureDirectory(candidate)) {
|
| 325 |
+
return null;
|
| 326 |
+
}
|
| 327 |
+
spritesPath = candidate;
|
| 328 |
+
return spritesPath;
|
| 329 |
+
};
|
| 330 |
+
|
| 331 |
+
const ensureMiscPath = () => {
|
| 332 |
+
if (miscPath) {
|
| 333 |
+
return miscPath;
|
| 334 |
+
}
|
| 335 |
+
// Use the image gallery path: user/images/{characterName}/
|
| 336 |
+
const candidate = path.join(directories.userImages, characterFolder);
|
| 337 |
+
if (!ensureDirectory(candidate)) {
|
| 338 |
+
return null;
|
| 339 |
+
}
|
| 340 |
+
miscPath = candidate;
|
| 341 |
+
return miscPath;
|
| 342 |
+
};
|
| 343 |
+
|
| 344 |
+
for (const asset of assets) {
|
| 345 |
+
if (!asset?.zipPath) {
|
| 346 |
+
continue;
|
| 347 |
+
}
|
| 348 |
+
const buffer = bufferMap.get(asset.zipPath);
|
| 349 |
+
if (!buffer) {
|
| 350 |
+
console.warn(`CharX: Asset ${asset.zipPath} missing or unsupported, skipping.`);
|
| 351 |
+
continue;
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
try {
|
| 355 |
+
if (asset.storageCategory === 'sprite') {
|
| 356 |
+
const targetDir = ensureSpritesPath();
|
| 357 |
+
if (!targetDir) {
|
| 358 |
+
continue;
|
| 359 |
+
}
|
| 360 |
+
// Delete existing sprite with same base name (any extension) - matches sprites.js behavior
|
| 361 |
+
deleteExistingByBaseName(targetDir, asset.baseName);
|
| 362 |
+
const filePath = path.join(targetDir, `${asset.baseName}.${asset.ext || 'png'}`);
|
| 363 |
+
writeFileAtomicSync(filePath, buffer);
|
| 364 |
+
summary.sprites += 1;
|
| 365 |
+
continue;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
if (asset.storageCategory === 'background') {
|
| 369 |
+
// Store in character-specific backgrounds folder: characters/{charName}/backgrounds/
|
| 370 |
+
const backgroundDir = path.join(directories.characters, characterFolder, 'backgrounds');
|
| 371 |
+
if (!ensureDirectory(backgroundDir)) {
|
| 372 |
+
continue;
|
| 373 |
+
}
|
| 374 |
+
// Delete existing background with same base name
|
| 375 |
+
deleteExistingByBaseName(backgroundDir, asset.baseName);
|
| 376 |
+
const fileName = `${asset.baseName}.${asset.ext || 'png'}`;
|
| 377 |
+
const filePath = path.join(backgroundDir, fileName);
|
| 378 |
+
writeFileAtomicSync(filePath, buffer);
|
| 379 |
+
summary.backgrounds += 1;
|
| 380 |
+
continue;
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
if (asset.storageCategory === 'misc') {
|
| 384 |
+
const miscDir = ensureMiscPath();
|
| 385 |
+
if (!miscDir) {
|
| 386 |
+
continue;
|
| 387 |
+
}
|
| 388 |
+
// Overwrite existing misc asset with same name
|
| 389 |
+
const filePath = path.join(miscDir, `${asset.baseName}.${asset.ext || 'png'}`);
|
| 390 |
+
writeFileAtomicSync(filePath, buffer);
|
| 391 |
+
summary.misc += 1;
|
| 392 |
+
}
|
| 393 |
+
} catch (error) {
|
| 394 |
+
console.warn(`CharX: Failed to save asset "${asset.name}": ${error.message}`);
|
| 395 |
+
}
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
return summary;
|
| 399 |
+
}
|
src/command-line.js
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from 'node:fs';
|
| 2 |
+
import path from 'node:path';
|
| 3 |
+
import yargs from 'yargs/yargs';
|
| 4 |
+
import { hideBin } from 'yargs/helpers';
|
| 5 |
+
import ipRegex from 'ip-regex';
|
| 6 |
+
import envPaths from 'env-paths';
|
| 7 |
+
import { color, getConfigValue, stringToBool } from './util.js';
|
| 8 |
+
import { initConfig } from './config-init.js';
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* @typedef {object} CommandLineArguments Parsed command line arguments
|
| 12 |
+
* @property {string} configPath Path to the config file
|
| 13 |
+
* @property {string} dataRoot Data root directory
|
| 14 |
+
* @property {number} port Port number
|
| 15 |
+
* @property {boolean} listen If TavernIntern is listening on all network interfaces
|
| 16 |
+
* @property {string} listenAddressIPv6 IPv6 address to listen to
|
| 17 |
+
* @property {string} listenAddressIPv4 IPv4 address to listen to
|
| 18 |
+
* @property {boolean|string} enableIPv4 If enable IPv4 protocol ("auto" is also allowed)
|
| 19 |
+
* @property {boolean|string} enableIPv6 If enable IPv6 protocol ("auto" is also allowed)
|
| 20 |
+
* @property {boolean} dnsPreferIPv6 If prefer IPv6 for DNS
|
| 21 |
+
* @property {boolean} browserLaunchEnabled If automatically launch TavernIntern in the browser
|
| 22 |
+
* @property {string} browserLaunchHostname Browser launch hostname
|
| 23 |
+
* @property {number} browserLaunchPort Browser launch port override (-1 is use server port)
|
| 24 |
+
* @property {boolean} browserLaunchAvoidLocalhost If avoid using 'localhost' for browser launch in auto mode
|
| 25 |
+
* @property {boolean} enableCorsProxy If enable CORS proxy
|
| 26 |
+
* @property {boolean} disableCsrf If disable CSRF protection
|
| 27 |
+
* @property {boolean} ssl If enable SSL
|
| 28 |
+
* @property {string} certPath Path to certificate
|
| 29 |
+
* @property {string} keyPath Path to private key
|
| 30 |
+
* @property {string} keyPassphrase SSL private key passphrase
|
| 31 |
+
* @property {boolean} whitelistMode If enable whitelist mode
|
| 32 |
+
* @property {boolean} basicAuthMode If enable basic authentication
|
| 33 |
+
* @property {boolean} requestProxyEnabled If enable outgoing request proxy
|
| 34 |
+
* @property {string} requestProxyUrl Request proxy URL
|
| 35 |
+
* @property {string[]} requestProxyBypass Request proxy bypass list
|
| 36 |
+
* @property {function(): URL} getIPv4ListenUrl Get IPv4 listen URL
|
| 37 |
+
* @property {function(): URL} getIPv6ListenUrl Get IPv6 listen URL
|
| 38 |
+
* @property {function(import('./server-startup.js').ServerStartupResult): Promise<string>} getBrowserLaunchHostname Get browser launch hostname
|
| 39 |
+
* @property {function(string): URL} getBrowserLaunchUrl Get browser launch URL
|
| 40 |
+
*/
|
| 41 |
+
|
| 42 |
+
/**
|
| 43 |
+
* Provides a command line arguments parser.
|
| 44 |
+
*/
|
| 45 |
+
export class CommandLineParser {
|
| 46 |
+
/**
|
| 47 |
+
* Gets the default configuration values.
|
| 48 |
+
* @param {boolean} isGlobal If the configuration is global or not
|
| 49 |
+
* @returns {CommandLineArguments} Default configuration values
|
| 50 |
+
*/
|
| 51 |
+
getDefaultConfig(isGlobal) {
|
| 52 |
+
const appPaths = envPaths('TavernIntern', { suffix: '' });
|
| 53 |
+
const configPath = isGlobal ? path.join(appPaths.data, 'config.yaml') : './config.yaml';
|
| 54 |
+
const dataPath = isGlobal ? path.join(appPaths.data, 'data') : './data';
|
| 55 |
+
return Object.freeze({
|
| 56 |
+
configPath: configPath,
|
| 57 |
+
dataRoot: dataPath,
|
| 58 |
+
port: 8000,
|
| 59 |
+
listen: false,
|
| 60 |
+
listenAddressIPv6: '[::]',
|
| 61 |
+
listenAddressIPv4: '0.0.0.0',
|
| 62 |
+
enableIPv4: true,
|
| 63 |
+
enableIPv6: false,
|
| 64 |
+
dnsPreferIPv6: false,
|
| 65 |
+
browserLaunchEnabled: false,
|
| 66 |
+
browserLaunchHostname: 'auto',
|
| 67 |
+
browserLaunchPort: -1,
|
| 68 |
+
browserLaunchAvoidLocalhost: false,
|
| 69 |
+
enableCorsProxy: false,
|
| 70 |
+
disableCsrf: false,
|
| 71 |
+
ssl: false,
|
| 72 |
+
certPath: 'certs/cert.pem',
|
| 73 |
+
keyPath: 'certs/privkey.pem',
|
| 74 |
+
keyPassphrase: '',
|
| 75 |
+
whitelistMode: true,
|
| 76 |
+
basicAuthMode: false,
|
| 77 |
+
requestProxyEnabled: false,
|
| 78 |
+
requestProxyUrl: '',
|
| 79 |
+
requestProxyBypass: [],
|
| 80 |
+
getIPv4ListenUrl: function () {
|
| 81 |
+
throw new Error('getIPv4ListenUrl is not implemented');
|
| 82 |
+
},
|
| 83 |
+
getIPv6ListenUrl: function () {
|
| 84 |
+
throw new Error('getIPv6ListenUrl is not implemented');
|
| 85 |
+
},
|
| 86 |
+
getBrowserLaunchHostname: async function () {
|
| 87 |
+
throw new Error('getBrowserLaunchHostname is not implemented');
|
| 88 |
+
},
|
| 89 |
+
getBrowserLaunchUrl: function () {
|
| 90 |
+
throw new Error('getBrowserLaunchUrl is not implemented');
|
| 91 |
+
},
|
| 92 |
+
});
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
constructor() {
|
| 96 |
+
this.booleanAutoOptions = [true, false, 'auto'];
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
/**
|
| 100 |
+
* Parses command line arguments.
|
| 101 |
+
* Arguments that are not provided will be filled with config values.
|
| 102 |
+
* @param {string[]} args Process startup arguments.
|
| 103 |
+
* @returns {CommandLineArguments} Parsed command line arguments.
|
| 104 |
+
*/
|
| 105 |
+
parse(args) {
|
| 106 |
+
const cliArguments = yargs(hideBin(args))
|
| 107 |
+
.usage('Usage: <your-start-script> [options]\nOptions that are not provided will be filled with config values.')
|
| 108 |
+
.option('global', {
|
| 109 |
+
type: 'boolean',
|
| 110 |
+
default: null,
|
| 111 |
+
describe: 'Use global data and config paths instead of the server directory',
|
| 112 |
+
})
|
| 113 |
+
.option('configPath', {
|
| 114 |
+
type: 'string',
|
| 115 |
+
default: null,
|
| 116 |
+
describe: 'Path to the config file (only for standalone mode)',
|
| 117 |
+
})
|
| 118 |
+
.option('enableIPv6', {
|
| 119 |
+
type: 'string',
|
| 120 |
+
default: null,
|
| 121 |
+
describe: 'Enables IPv6 protocol',
|
| 122 |
+
})
|
| 123 |
+
.option('enableIPv4', {
|
| 124 |
+
type: 'string',
|
| 125 |
+
default: null,
|
| 126 |
+
describe: 'Enables IPv4 protocol',
|
| 127 |
+
})
|
| 128 |
+
.option('port', {
|
| 129 |
+
type: 'number',
|
| 130 |
+
default: null,
|
| 131 |
+
describe: 'Sets the server listening port',
|
| 132 |
+
})
|
| 133 |
+
.option('dnsPreferIPv6', {
|
| 134 |
+
type: 'boolean',
|
| 135 |
+
default: null,
|
| 136 |
+
describe: 'Prefers IPv6 for DNS\nYou should probably have the enabled if you\'re on an IPv6 only network',
|
| 137 |
+
})
|
| 138 |
+
.option('browserLaunchEnabled', {
|
| 139 |
+
type: 'boolean',
|
| 140 |
+
default: null,
|
| 141 |
+
describe: 'Automatically launch TavernIntern in the browser',
|
| 142 |
+
})
|
| 143 |
+
.option('browserLaunchHostname', {
|
| 144 |
+
type: 'string',
|
| 145 |
+
default: null,
|
| 146 |
+
describe: 'Sets the browser launch hostname, best left on \'auto\'.\nUse values like \'localhost\', \'st.example.com\'',
|
| 147 |
+
})
|
| 148 |
+
.option('browserLaunchPort', {
|
| 149 |
+
type: 'number',
|
| 150 |
+
default: null,
|
| 151 |
+
describe: 'Overrides the port for browser launch with open your browser with this port and ignore what port the server is running on. -1 is use server port',
|
| 152 |
+
})
|
| 153 |
+
.option('browserLaunchAvoidLocalhost', {
|
| 154 |
+
type: 'boolean',
|
| 155 |
+
default: null,
|
| 156 |
+
describe: 'Avoids using \'localhost\' for browser launch in auto mode.\nUse if you don\'t have \'localhost\' in your hosts file',
|
| 157 |
+
})
|
| 158 |
+
.option('listen', {
|
| 159 |
+
type: 'boolean',
|
| 160 |
+
default: null,
|
| 161 |
+
describe: 'Whether to listen on all network interfaces',
|
| 162 |
+
})
|
| 163 |
+
.option('listenAddressIPv6', {
|
| 164 |
+
type: 'string',
|
| 165 |
+
default: null,
|
| 166 |
+
describe: 'Specific IPv6 address to listen to',
|
| 167 |
+
})
|
| 168 |
+
.option('listenAddressIPv4', {
|
| 169 |
+
type: 'string',
|
| 170 |
+
default: null,
|
| 171 |
+
describe: 'Specific IPv4 address to listen to',
|
| 172 |
+
})
|
| 173 |
+
.option('corsProxy', {
|
| 174 |
+
type: 'boolean',
|
| 175 |
+
default: null,
|
| 176 |
+
describe: 'Enables CORS proxy',
|
| 177 |
+
})
|
| 178 |
+
.option('disableCsrf', {
|
| 179 |
+
type: 'boolean',
|
| 180 |
+
default: null,
|
| 181 |
+
describe: 'Disables CSRF protection - NOT RECOMMENDED',
|
| 182 |
+
})
|
| 183 |
+
.option('ssl', {
|
| 184 |
+
type: 'boolean',
|
| 185 |
+
default: null,
|
| 186 |
+
describe: 'Enables SSL',
|
| 187 |
+
})
|
| 188 |
+
.option('certPath', {
|
| 189 |
+
type: 'string',
|
| 190 |
+
default: null,
|
| 191 |
+
describe: 'Path to SSL certificate file',
|
| 192 |
+
})
|
| 193 |
+
.option('keyPath', {
|
| 194 |
+
type: 'string',
|
| 195 |
+
default: null,
|
| 196 |
+
describe: 'Path to SSL private key file',
|
| 197 |
+
})
|
| 198 |
+
.option('keyPassphrase', {
|
| 199 |
+
type: 'string',
|
| 200 |
+
default: null,
|
| 201 |
+
describe: 'Passphrase for the SSL private key',
|
| 202 |
+
})
|
| 203 |
+
.option('whitelist', {
|
| 204 |
+
type: 'boolean',
|
| 205 |
+
default: null,
|
| 206 |
+
describe: 'Enables whitelist mode',
|
| 207 |
+
})
|
| 208 |
+
.option('dataRoot', {
|
| 209 |
+
type: 'string',
|
| 210 |
+
default: null,
|
| 211 |
+
describe: 'Root directory for data storage (only for standalone mode)',
|
| 212 |
+
})
|
| 213 |
+
.option('basicAuthMode', {
|
| 214 |
+
type: 'boolean',
|
| 215 |
+
default: null,
|
| 216 |
+
describe: 'Enables basic authentication',
|
| 217 |
+
})
|
| 218 |
+
.option('requestProxyEnabled', {
|
| 219 |
+
type: 'boolean',
|
| 220 |
+
default: null,
|
| 221 |
+
describe: 'Enables a use of proxy for outgoing requests',
|
| 222 |
+
})
|
| 223 |
+
.option('requestProxyUrl', {
|
| 224 |
+
type: 'string',
|
| 225 |
+
default: null,
|
| 226 |
+
describe: 'Request proxy URL (HTTP or SOCKS protocols)',
|
| 227 |
+
})
|
| 228 |
+
.option('requestProxyBypass', {
|
| 229 |
+
type: 'array',
|
| 230 |
+
describe: 'Request proxy bypass list (space separated list of hosts)',
|
| 231 |
+
})
|
| 232 |
+
/* DEPRECATED options */
|
| 233 |
+
.option('autorun', {
|
| 234 |
+
type: 'boolean',
|
| 235 |
+
default: null,
|
| 236 |
+
describe: 'DEPRECATED: Use "browserLaunchEnabled" instead.',
|
| 237 |
+
})
|
| 238 |
+
.option('autorunHostname', {
|
| 239 |
+
type: 'string',
|
| 240 |
+
default: null,
|
| 241 |
+
describe: 'DEPRECATED: Use "browserLaunchHostname" instead.',
|
| 242 |
+
})
|
| 243 |
+
.option('autorunPortOverride', {
|
| 244 |
+
type: 'number',
|
| 245 |
+
default: null,
|
| 246 |
+
describe: 'DEPRECATED: Use "browserLaunchPort" instead.',
|
| 247 |
+
})
|
| 248 |
+
.option('avoidLocalhost', {
|
| 249 |
+
type: 'boolean',
|
| 250 |
+
default: null,
|
| 251 |
+
describe: 'DEPRECATED: Use "browserLaunchAvoidLocalhost" instead.',
|
| 252 |
+
})
|
| 253 |
+
.parseSync();
|
| 254 |
+
|
| 255 |
+
const isGlobal = globalThis.FORCE_GLOBAL_MODE ?? cliArguments.global ?? false;
|
| 256 |
+
const defaultConfig = this.getDefaultConfig(isGlobal);
|
| 257 |
+
|
| 258 |
+
if (isGlobal && cliArguments.configPath) {
|
| 259 |
+
console.warn(color.yellow('Warning: "--configPath" argument is ignored in global mode'));
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
if (isGlobal && cliArguments.dataRoot) {
|
| 263 |
+
console.warn(color.yellow('Warning: "--dataRoot" argument is ignored in global mode'));
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
const configPath = isGlobal
|
| 267 |
+
? defaultConfig.configPath
|
| 268 |
+
: (cliArguments.configPath ?? defaultConfig.configPath);
|
| 269 |
+
if (isGlobal && !fs.existsSync(path.dirname(configPath))) {
|
| 270 |
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
| 271 |
+
}
|
| 272 |
+
initConfig(configPath);
|
| 273 |
+
|
| 274 |
+
const dataRoot = isGlobal
|
| 275 |
+
? defaultConfig.dataRoot
|
| 276 |
+
: (cliArguments.dataRoot ?? getConfigValue('dataRoot', defaultConfig.dataRoot));
|
| 277 |
+
if (isGlobal && !fs.existsSync(dataRoot)) {
|
| 278 |
+
fs.mkdirSync(dataRoot, { recursive: true });
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
/** @type {CommandLineArguments} */
|
| 282 |
+
const result = {
|
| 283 |
+
configPath: configPath,
|
| 284 |
+
dataRoot: dataRoot,
|
| 285 |
+
port: cliArguments.port ?? getConfigValue('port', defaultConfig.port, 'number'),
|
| 286 |
+
listen: cliArguments.listen ?? getConfigValue('listen', defaultConfig.listen, 'boolean'),
|
| 287 |
+
listenAddressIPv6: cliArguments.listenAddressIPv6 ?? getConfigValue('listenAddress.ipv6', defaultConfig.listenAddressIPv6),
|
| 288 |
+
listenAddressIPv4: cliArguments.listenAddressIPv4 ?? getConfigValue('listenAddress.ipv4', defaultConfig.listenAddressIPv4),
|
| 289 |
+
enableIPv4: stringToBool(cliArguments.enableIPv4) ?? stringToBool(getConfigValue('protocol.ipv4', defaultConfig.enableIPv4)) ?? defaultConfig.enableIPv4,
|
| 290 |
+
enableIPv6: stringToBool(cliArguments.enableIPv6) ?? stringToBool(getConfigValue('protocol.ipv6', defaultConfig.enableIPv6)) ?? defaultConfig.enableIPv6,
|
| 291 |
+
dnsPreferIPv6: cliArguments.dnsPreferIPv6 ?? getConfigValue('dnsPreferIPv6', defaultConfig.dnsPreferIPv6, 'boolean'),
|
| 292 |
+
browserLaunchEnabled: cliArguments.browserLaunchEnabled ?? cliArguments.autorun ?? getConfigValue('browserLaunch.enabled', defaultConfig.browserLaunchEnabled, 'boolean'),
|
| 293 |
+
browserLaunchHostname: cliArguments.browserLaunchHostname ?? cliArguments.autorunHostname ?? getConfigValue('browserLaunch.hostname', defaultConfig.browserLaunchHostname),
|
| 294 |
+
browserLaunchPort: cliArguments.browserLaunchPort ?? cliArguments.autorunPortOverride ?? getConfigValue('browserLaunch.port', defaultConfig.browserLaunchPort, 'number'),
|
| 295 |
+
browserLaunchAvoidLocalhost: cliArguments.browserLaunchAvoidLocalhost ?? cliArguments.avoidLocalhost ?? getConfigValue('browserLaunch.avoidLocalhost', defaultConfig.browserLaunchAvoidLocalhost, 'boolean'),
|
| 296 |
+
enableCorsProxy: cliArguments.corsProxy ?? getConfigValue('enableCorsProxy', defaultConfig.enableCorsProxy, 'boolean'),
|
| 297 |
+
disableCsrf: cliArguments.disableCsrf ?? getConfigValue('disableCsrfProtection', defaultConfig.disableCsrf, 'boolean'),
|
| 298 |
+
ssl: cliArguments.ssl ?? getConfigValue('ssl.enabled', defaultConfig.ssl, 'boolean'),
|
| 299 |
+
certPath: cliArguments.certPath ?? getConfigValue('ssl.certPath', defaultConfig.certPath),
|
| 300 |
+
keyPath: cliArguments.keyPath ?? getConfigValue('ssl.keyPath', defaultConfig.keyPath),
|
| 301 |
+
keyPassphrase: cliArguments.keyPassphrase ?? getConfigValue('ssl.keyPassphrase', defaultConfig.keyPassphrase),
|
| 302 |
+
whitelistMode: cliArguments.whitelist ?? getConfigValue('whitelistMode', defaultConfig.whitelistMode, 'boolean'),
|
| 303 |
+
basicAuthMode: cliArguments.basicAuthMode ?? getConfigValue('basicAuthMode', defaultConfig.basicAuthMode, 'boolean'),
|
| 304 |
+
requestProxyEnabled: cliArguments.requestProxyEnabled ?? getConfigValue('requestProxy.enabled', defaultConfig.requestProxyEnabled, 'boolean'),
|
| 305 |
+
requestProxyUrl: cliArguments.requestProxyUrl ?? getConfigValue('requestProxy.url', defaultConfig.requestProxyUrl),
|
| 306 |
+
requestProxyBypass: cliArguments.requestProxyBypass ?? getConfigValue('requestProxy.bypass', defaultConfig.requestProxyBypass),
|
| 307 |
+
getIPv4ListenUrl: function () {
|
| 308 |
+
const isValid = ipRegex.v4({ exact: true }).test(this.listenAddressIPv4);
|
| 309 |
+
return new URL(
|
| 310 |
+
(this.ssl ? 'https://' : 'http://') +
|
| 311 |
+
(this.listen ? (isValid ? this.listenAddressIPv4 : '0.0.0.0') : '127.0.0.1') +
|
| 312 |
+
(':' + this.port),
|
| 313 |
+
);
|
| 314 |
+
},
|
| 315 |
+
getIPv6ListenUrl: function () {
|
| 316 |
+
const isValid = ipRegex.v6({ exact: true }).test(this.listenAddressIPv6);
|
| 317 |
+
return new URL(
|
| 318 |
+
(this.ssl ? 'https://' : 'http://') +
|
| 319 |
+
(this.listen ? (isValid ? this.listenAddressIPv6 : '[::]') : '[::1]') +
|
| 320 |
+
(':' + this.port),
|
| 321 |
+
);
|
| 322 |
+
},
|
| 323 |
+
getBrowserLaunchHostname: async function ({ useIPv6, useIPv4 }) {
|
| 324 |
+
if (this.browserLaunchHostname === 'auto') {
|
| 325 |
+
if (useIPv6 && useIPv4) {
|
| 326 |
+
return this.browserLaunchAvoidLocalhost ? '[::1]' : 'localhost';
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
if (useIPv6) {
|
| 330 |
+
return '[::1]';
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
if (useIPv4) {
|
| 334 |
+
return '127.0.0.1';
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
return this.browserLaunchHostname;
|
| 339 |
+
},
|
| 340 |
+
getBrowserLaunchUrl: function (hostname) {
|
| 341 |
+
const browserLaunchPort = (this.browserLaunchPort >= 0) ? this.browserLaunchPort : this.port;
|
| 342 |
+
return new URL(
|
| 343 |
+
(this.ssl ? 'https://' : 'http://') +
|
| 344 |
+
(hostname) +
|
| 345 |
+
(':') +
|
| 346 |
+
(browserLaunchPort),
|
| 347 |
+
);
|
| 348 |
+
},
|
| 349 |
+
};
|
| 350 |
+
|
| 351 |
+
if (!this.booleanAutoOptions.includes(result.enableIPv6)) {
|
| 352 |
+
console.warn(color.red('`protocol: ipv6` option invalid'), '\n use:', this.booleanAutoOptions, '\n setting to:', defaultConfig.enableIPv6);
|
| 353 |
+
result.enableIPv6 = defaultConfig.enableIPv6;
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
if (!this.booleanAutoOptions.includes(result.enableIPv4)) {
|
| 357 |
+
console.warn(color.red('`protocol: ipv4` option invalid'), '\n use:', this.booleanAutoOptions, '\n setting to:', defaultConfig.enableIPv4);
|
| 358 |
+
result.enableIPv4 = defaultConfig.enableIPv4;
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
return result;
|
| 362 |
+
}
|
| 363 |
+
}
|
src/config-init.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from 'node:fs';
|
| 2 |
+
import path from 'node:path';
|
| 3 |
+
import yaml from 'yaml';
|
| 4 |
+
import color from 'chalk';
|
| 5 |
+
import _ from 'lodash';
|
| 6 |
+
import { serverDirectory } from './server-directory.js';
|
| 7 |
+
import { keyToEnv, setConfigFilePath } from './util.js';
|
| 8 |
+
|
| 9 |
+
const keyMigrationMap = [
|
| 10 |
+
{
|
| 11 |
+
oldKey: 'disableThumbnails',
|
| 12 |
+
newKey: 'thumbnails.enabled',
|
| 13 |
+
migrate: (value) => !value,
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
oldKey: 'thumbnailsQuality',
|
| 17 |
+
newKey: 'thumbnails.quality',
|
| 18 |
+
migrate: (value) => value,
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
oldKey: 'avatarThumbnailsPng',
|
| 22 |
+
newKey: 'thumbnails.format',
|
| 23 |
+
migrate: (value) => (value ? 'png' : 'jpg'),
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
oldKey: 'disableChatBackup',
|
| 27 |
+
newKey: 'backups.chat.enabled',
|
| 28 |
+
migrate: (value) => !value,
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
oldKey: 'numberOfBackups',
|
| 32 |
+
newKey: 'backups.common.numberOfBackups',
|
| 33 |
+
migrate: (value) => value,
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
oldKey: 'maxTotalChatBackups',
|
| 37 |
+
newKey: 'backups.chat.maxTotalBackups',
|
| 38 |
+
migrate: (value) => value,
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
oldKey: 'chatBackupThrottleInterval',
|
| 42 |
+
newKey: 'backups.chat.throttleInterval',
|
| 43 |
+
migrate: (value) => value,
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
oldKey: 'enableExtensions',
|
| 47 |
+
newKey: 'extensions.enabled',
|
| 48 |
+
migrate: (value) => value,
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
oldKey: 'enableExtensionsAutoUpdate',
|
| 52 |
+
newKey: 'extensions.autoUpdate',
|
| 53 |
+
migrate: (value) => value,
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
oldKey: 'extras.disableAutoDownload',
|
| 57 |
+
newKey: 'extensions.models.autoDownload',
|
| 58 |
+
migrate: (value) => !value,
|
| 59 |
+
},
|
| 60 |
+
{
|
| 61 |
+
oldKey: 'extras.classificationModel',
|
| 62 |
+
newKey: 'extensions.models.classification',
|
| 63 |
+
migrate: (value) => value,
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
oldKey: 'extras.captioningModel',
|
| 67 |
+
newKey: 'extensions.models.captioning',
|
| 68 |
+
migrate: (value) => value,
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
oldKey: 'extras.embeddingModel',
|
| 72 |
+
newKey: 'extensions.models.embedding',
|
| 73 |
+
migrate: (value) => value,
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
oldKey: 'extras.speechToTextModel',
|
| 77 |
+
newKey: 'extensions.models.speechToText',
|
| 78 |
+
migrate: (value) => value,
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
oldKey: 'extras.textToSpeechModel',
|
| 82 |
+
newKey: 'extensions.models.textToSpeech',
|
| 83 |
+
migrate: (value) => value,
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
oldKey: 'minLogLevel',
|
| 87 |
+
newKey: 'logging.minLogLevel',
|
| 88 |
+
migrate: (value) => value,
|
| 89 |
+
},
|
| 90 |
+
{
|
| 91 |
+
oldKey: 'cardsCacheCapacity',
|
| 92 |
+
newKey: 'performance.memoryCacheCapacity',
|
| 93 |
+
migrate: (value) => `${value}mb`,
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
oldKey: 'cookieSecret',
|
| 97 |
+
newKey: 'cookieSecret',
|
| 98 |
+
migrate: () => void 0,
|
| 99 |
+
remove: true,
|
| 100 |
+
},
|
| 101 |
+
{
|
| 102 |
+
oldKey: 'autorun',
|
| 103 |
+
newKey: 'browserLaunch.enabled',
|
| 104 |
+
migrate: (value) => value,
|
| 105 |
+
},
|
| 106 |
+
{
|
| 107 |
+
oldKey: 'autorunHostname',
|
| 108 |
+
newKey: 'browserLaunch.hostname',
|
| 109 |
+
migrate: (value) => value,
|
| 110 |
+
},
|
| 111 |
+
{
|
| 112 |
+
oldKey: 'autorunPortOverride',
|
| 113 |
+
newKey: 'browserLaunch.port',
|
| 114 |
+
migrate: (value) => value,
|
| 115 |
+
},
|
| 116 |
+
{
|
| 117 |
+
oldKey: 'avoidLocalhost',
|
| 118 |
+
newKey: 'browserLaunch.avoidLocalhost',
|
| 119 |
+
migrate: (value) => value,
|
| 120 |
+
},
|
| 121 |
+
{
|
| 122 |
+
oldKey: 'extras.promptExpansionModel',
|
| 123 |
+
newKey: 'extras.promptExpansionModel',
|
| 124 |
+
migrate: () => void 0,
|
| 125 |
+
remove: true,
|
| 126 |
+
},
|
| 127 |
+
{
|
| 128 |
+
oldKey: 'autheliaAuth',
|
| 129 |
+
newKey: 'sso.autheliaAuth',
|
| 130 |
+
migrate: (value) => value,
|
| 131 |
+
},
|
| 132 |
+
{
|
| 133 |
+
oldKey: 'authentikAuth',
|
| 134 |
+
newKey: 'sso.authentikAuth',
|
| 135 |
+
migrate: (value) => value,
|
| 136 |
+
},
|
| 137 |
+
];
|
| 138 |
+
|
| 139 |
+
/**
|
| 140 |
+
* Gets all keys from an object recursively.
|
| 141 |
+
* @param {object} obj Object to get all keys from
|
| 142 |
+
* @param {string} prefix Prefix to prepend to all keys
|
| 143 |
+
* @returns {string[]} Array of all keys in the object
|
| 144 |
+
*/
|
| 145 |
+
function getAllKeys(obj, prefix = '') {
|
| 146 |
+
if (typeof obj !== 'object' || Array.isArray(obj) || obj === null) {
|
| 147 |
+
return [];
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
return _.flatMap(Object.keys(obj), key => {
|
| 151 |
+
const newPrefix = prefix ? `${prefix}.${key}` : key;
|
| 152 |
+
if (typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
|
| 153 |
+
return getAllKeys(obj[key], newPrefix);
|
| 154 |
+
} else {
|
| 155 |
+
return [newPrefix];
|
| 156 |
+
}
|
| 157 |
+
});
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
/**
|
| 161 |
+
* Compares the current config.yaml with the default config.yaml and adds any missing values.
|
| 162 |
+
* @param {string} configPath Path to config.yaml
|
| 163 |
+
*/
|
| 164 |
+
export function addMissingConfigValues(configPath) {
|
| 165 |
+
try {
|
| 166 |
+
const defaultConfig = yaml.parse(fs.readFileSync(path.join(serverDirectory, './default/config.yaml'), 'utf8'));
|
| 167 |
+
|
| 168 |
+
if (!fs.existsSync(configPath)) {
|
| 169 |
+
console.warn(color.yellow(`Warning: config.yaml not found at ${configPath}. Creating a new one with default values.`));
|
| 170 |
+
fs.writeFileSync(configPath, yaml.stringify(defaultConfig));
|
| 171 |
+
return;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
let config = yaml.parse(fs.readFileSync(configPath, 'utf8'));
|
| 175 |
+
|
| 176 |
+
// Migrate old keys to new keys
|
| 177 |
+
const migratedKeys = [];
|
| 178 |
+
for (const { oldKey, newKey, migrate, remove } of keyMigrationMap) {
|
| 179 |
+
// Migrate environment variables
|
| 180 |
+
const oldEnvKey = keyToEnv(oldKey);
|
| 181 |
+
const newEnvKey = keyToEnv(newKey);
|
| 182 |
+
if (process.env[oldEnvKey] && !process.env[newEnvKey]) {
|
| 183 |
+
const oldValue = process.env[oldEnvKey];
|
| 184 |
+
const newValue = migrate(oldValue);
|
| 185 |
+
process.env[newEnvKey] = newValue;
|
| 186 |
+
delete process.env[oldEnvKey];
|
| 187 |
+
console.warn(color.yellow(`Warning: Using a deprecated environment variable: ${oldEnvKey}. Please use ${newEnvKey} instead.`));
|
| 188 |
+
console.log(`Redirecting ${color.blue(oldEnvKey)}=${oldValue} -> ${color.blue(newEnvKey)}=${newValue}`);
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
if (_.has(config, oldKey)) {
|
| 192 |
+
if (remove) {
|
| 193 |
+
_.unset(config, oldKey);
|
| 194 |
+
migratedKeys.push({
|
| 195 |
+
oldKey,
|
| 196 |
+
newValue: void 0,
|
| 197 |
+
});
|
| 198 |
+
continue;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
const oldValue = _.get(config, oldKey);
|
| 202 |
+
const newValue = migrate(oldValue);
|
| 203 |
+
_.set(config, newKey, newValue);
|
| 204 |
+
_.unset(config, oldKey);
|
| 205 |
+
|
| 206 |
+
migratedKeys.push({
|
| 207 |
+
oldKey,
|
| 208 |
+
newKey,
|
| 209 |
+
oldValue,
|
| 210 |
+
newValue,
|
| 211 |
+
});
|
| 212 |
+
}
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
// Get all keys from the original config
|
| 216 |
+
const originalKeys = getAllKeys(config);
|
| 217 |
+
|
| 218 |
+
// Use lodash's defaultsDeep function to recursively apply default properties
|
| 219 |
+
config = _.defaultsDeep(config, defaultConfig);
|
| 220 |
+
|
| 221 |
+
// Get all keys from the updated config
|
| 222 |
+
const updatedKeys = getAllKeys(config);
|
| 223 |
+
|
| 224 |
+
// Find the keys that were added
|
| 225 |
+
const addedKeys = _.difference(updatedKeys, originalKeys);
|
| 226 |
+
|
| 227 |
+
if (addedKeys.length === 0 && migratedKeys.length === 0) {
|
| 228 |
+
return;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
if (addedKeys.length > 0) {
|
| 232 |
+
console.log('Adding missing config values to config.yaml:', addedKeys);
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
if (migratedKeys.length > 0) {
|
| 236 |
+
console.log('Migrating config values in config.yaml:', migratedKeys);
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
fs.writeFileSync(configPath, yaml.stringify(config));
|
| 240 |
+
} catch (error) {
|
| 241 |
+
console.error(color.red('FATAL: Could not add missing config values to config.yaml'), error);
|
| 242 |
+
}
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
/**
|
| 246 |
+
* Performs early initialization tasks before the server starts.
|
| 247 |
+
* @param {string} configPath Path to config.yaml
|
| 248 |
+
*/
|
| 249 |
+
export function initConfig(configPath) {
|
| 250 |
+
console.log('Using config path:', color.green(configPath));
|
| 251 |
+
setConfigFilePath(configPath);
|
| 252 |
+
addMissingConfigValues(configPath);
|
| 253 |
+
}
|
src/constants.js
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const PUBLIC_DIRECTORIES = {
|
| 2 |
+
images: 'public/img/',
|
| 3 |
+
backups: 'backups/',
|
| 4 |
+
sounds: 'public/sounds',
|
| 5 |
+
extensions: 'public/scripts/extensions',
|
| 6 |
+
globalExtensions: 'public/scripts/extensions/third-party',
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
export const SETTINGS_FILE = 'settings.json';
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* @type {import('./users.js').UserDirectoryList}
|
| 13 |
+
* @readonly
|
| 14 |
+
* @enum {string}
|
| 15 |
+
*/
|
| 16 |
+
export const USER_DIRECTORY_TEMPLATE = Object.freeze({
|
| 17 |
+
root: '',
|
| 18 |
+
thumbnails: 'thumbnails',
|
| 19 |
+
thumbnailsBg: 'thumbnails/bg',
|
| 20 |
+
thumbnailsAvatar: 'thumbnails/avatar',
|
| 21 |
+
thumbnailsPersona: 'thumbnails/persona',
|
| 22 |
+
worlds: 'worlds',
|
| 23 |
+
user: 'user',
|
| 24 |
+
avatars: 'User Avatars',
|
| 25 |
+
userImages: 'user/images',
|
| 26 |
+
groups: 'groups',
|
| 27 |
+
groupChats: 'group chats',
|
| 28 |
+
chats: 'chats',
|
| 29 |
+
characters: 'characters',
|
| 30 |
+
backgrounds: 'backgrounds',
|
| 31 |
+
novelAI_Settings: 'NovelAI Settings',
|
| 32 |
+
koboldAI_Settings: 'KoboldAI Settings',
|
| 33 |
+
openAI_Settings: 'OpenAI Settings',
|
| 34 |
+
textGen_Settings: 'TextGen Settings',
|
| 35 |
+
themes: 'themes',
|
| 36 |
+
movingUI: 'movingUI',
|
| 37 |
+
extensions: 'extensions',
|
| 38 |
+
instruct: 'instruct',
|
| 39 |
+
context: 'context',
|
| 40 |
+
quickreplies: 'QuickReplies',
|
| 41 |
+
assets: 'assets',
|
| 42 |
+
comfyWorkflows: 'user/workflows',
|
| 43 |
+
files: 'user/files',
|
| 44 |
+
vectors: 'vectors',
|
| 45 |
+
backups: 'backups',
|
| 46 |
+
sysprompt: 'sysprompt',
|
| 47 |
+
reasoning: 'reasoning',
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* @type {import('./users.js').User}
|
| 52 |
+
* @readonly
|
| 53 |
+
*/
|
| 54 |
+
export const DEFAULT_USER = Object.freeze({
|
| 55 |
+
handle: 'default-user',
|
| 56 |
+
name: 'User',
|
| 57 |
+
created: Date.now(),
|
| 58 |
+
password: '',
|
| 59 |
+
admin: true,
|
| 60 |
+
enabled: true,
|
| 61 |
+
salt: '',
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
export const UNSAFE_EXTENSIONS = [
|
| 65 |
+
'.php',
|
| 66 |
+
'.exe',
|
| 67 |
+
'.com',
|
| 68 |
+
'.dll',
|
| 69 |
+
'.pif',
|
| 70 |
+
'.application',
|
| 71 |
+
'.gadget',
|
| 72 |
+
'.msi',
|
| 73 |
+
'.jar',
|
| 74 |
+
'.cmd',
|
| 75 |
+
'.bat',
|
| 76 |
+
'.reg',
|
| 77 |
+
'.sh',
|
| 78 |
+
'.py',
|
| 79 |
+
'.js',
|
| 80 |
+
'.jse',
|
| 81 |
+
'.jsp',
|
| 82 |
+
'.pdf',
|
| 83 |
+
'.html',
|
| 84 |
+
'.htm',
|
| 85 |
+
'.hta',
|
| 86 |
+
'.vb',
|
| 87 |
+
'.vbs',
|
| 88 |
+
'.vbe',
|
| 89 |
+
'.cpl',
|
| 90 |
+
'.msc',
|
| 91 |
+
'.scr',
|
| 92 |
+
'.sql',
|
| 93 |
+
'.iso',
|
| 94 |
+
'.img',
|
| 95 |
+
'.dmg',
|
| 96 |
+
'.ps1',
|
| 97 |
+
'.ps1xml',
|
| 98 |
+
'.ps2',
|
| 99 |
+
'.ps2xml',
|
| 100 |
+
'.psc1',
|
| 101 |
+
'.psc2',
|
| 102 |
+
'.msh',
|
| 103 |
+
'.msh1',
|
| 104 |
+
'.msh2',
|
| 105 |
+
'.mshxml',
|
| 106 |
+
'.msh1xml',
|
| 107 |
+
'.msh2xml',
|
| 108 |
+
'.scf',
|
| 109 |
+
'.lnk',
|
| 110 |
+
'.inf',
|
| 111 |
+
'.reg',
|
| 112 |
+
'.doc',
|
| 113 |
+
'.docm',
|
| 114 |
+
'.docx',
|
| 115 |
+
'.dot',
|
| 116 |
+
'.dotm',
|
| 117 |
+
'.dotx',
|
| 118 |
+
'.xls',
|
| 119 |
+
'.xlsm',
|
| 120 |
+
'.xlsx',
|
| 121 |
+
'.xlt',
|
| 122 |
+
'.xltm',
|
| 123 |
+
'.xltx',
|
| 124 |
+
'.xlam',
|
| 125 |
+
'.ppt',
|
| 126 |
+
'.pptm',
|
| 127 |
+
'.pptx',
|
| 128 |
+
'.pot',
|
| 129 |
+
'.potm',
|
| 130 |
+
'.potx',
|
| 131 |
+
'.ppam',
|
| 132 |
+
'.ppsx',
|
| 133 |
+
'.ppsm',
|
| 134 |
+
'.pps',
|
| 135 |
+
'.ppam',
|
| 136 |
+
'.sldx',
|
| 137 |
+
'.sldm',
|
| 138 |
+
'.ws',
|
| 139 |
+
];
|
| 140 |
+
|
| 141 |
+
export const GEMINI_SAFETY = [
|
| 142 |
+
{
|
| 143 |
+
category: 'HARM_CATEGORY_HARASSMENT',
|
| 144 |
+
threshold: 'OFF',
|
| 145 |
+
},
|
| 146 |
+
{
|
| 147 |
+
category: 'HARM_CATEGORY_HATE_SPEECH',
|
| 148 |
+
threshold: 'OFF',
|
| 149 |
+
},
|
| 150 |
+
{
|
| 151 |
+
category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
|
| 152 |
+
threshold: 'OFF',
|
| 153 |
+
},
|
| 154 |
+
{
|
| 155 |
+
category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
|
| 156 |
+
threshold: 'OFF',
|
| 157 |
+
},
|
| 158 |
+
{
|
| 159 |
+
category: 'HARM_CATEGORY_CIVIC_INTEGRITY',
|
| 160 |
+
threshold: 'OFF',
|
| 161 |
+
},
|
| 162 |
+
];
|
| 163 |
+
|
| 164 |
+
export const VERTEX_SAFETY = [
|
| 165 |
+
{
|
| 166 |
+
category: 'HARM_CATEGORY_IMAGE_HATE',
|
| 167 |
+
threshold: 'OFF',
|
| 168 |
+
},
|
| 169 |
+
{
|
| 170 |
+
category: 'HARM_CATEGORY_IMAGE_DANGEROUS_CONTENT',
|
| 171 |
+
threshold: 'OFF',
|
| 172 |
+
},
|
| 173 |
+
{
|
| 174 |
+
category: 'HARM_CATEGORY_IMAGE_HARASSMENT',
|
| 175 |
+
threshold: 'OFF',
|
| 176 |
+
},
|
| 177 |
+
{
|
| 178 |
+
category: 'HARM_CATEGORY_IMAGE_SEXUALLY_EXPLICIT',
|
| 179 |
+
threshold: 'OFF',
|
| 180 |
+
},
|
| 181 |
+
{
|
| 182 |
+
category: 'HARM_CATEGORY_JAILBREAK',
|
| 183 |
+
threshold: 'OFF',
|
| 184 |
+
},
|
| 185 |
+
];
|
| 186 |
+
|
| 187 |
+
export const CHAT_COMPLETION_SOURCES = {
|
| 188 |
+
OPENAI: 'openai',
|
| 189 |
+
CLAUDE: 'claude',
|
| 190 |
+
OPENROUTER: 'openrouter',
|
| 191 |
+
AI21: 'ai21',
|
| 192 |
+
MAKERSUITE: 'makersuite',
|
| 193 |
+
VERTEXAI: 'vertexai',
|
| 194 |
+
MISTRALAI: 'mistralai',
|
| 195 |
+
CUSTOM: 'custom',
|
| 196 |
+
COHERE: 'cohere',
|
| 197 |
+
PERPLEXITY: 'perplexity',
|
| 198 |
+
GROQ: 'groq',
|
| 199 |
+
CHUTES: 'chutes',
|
| 200 |
+
ELECTRONHUB: 'electronhub',
|
| 201 |
+
NANOGPT: 'nanogpt',
|
| 202 |
+
DEEPSEEK: 'deepseek',
|
| 203 |
+
AIMLAPI: 'aimlapi',
|
| 204 |
+
XAI: 'xai',
|
| 205 |
+
POLLINATIONS: 'pollinations',
|
| 206 |
+
MOONSHOT: 'moonshot',
|
| 207 |
+
FIREWORKS: 'fireworks',
|
| 208 |
+
COMETAPI: 'cometapi',
|
| 209 |
+
AZURE_OPENAI: 'azure_openai',
|
| 210 |
+
ZAI: 'zai',
|
| 211 |
+
SILICONFLOW: 'siliconflow',
|
| 212 |
+
};
|
| 213 |
+
|
| 214 |
+
/**
|
| 215 |
+
* Path to multer file uploads under the data root.
|
| 216 |
+
*/
|
| 217 |
+
export const UPLOADS_DIRECTORY = '_uploads';
|
| 218 |
+
|
| 219 |
+
// TODO: this is copied from the client code; there should be a way to de-duplicate it eventually
|
| 220 |
+
export const TEXTGEN_TYPES = {
|
| 221 |
+
OOBA: 'ooba',
|
| 222 |
+
MANCER: 'mancer',
|
| 223 |
+
VLLM: 'vllm',
|
| 224 |
+
APHRODITE: 'aphrodite',
|
| 225 |
+
TABBY: 'tabby',
|
| 226 |
+
KOBOLDCPP: 'koboldcpp',
|
| 227 |
+
TOGETHERAI: 'togetherai',
|
| 228 |
+
LLAMACPP: 'llamacpp',
|
| 229 |
+
OLLAMA: 'ollama',
|
| 230 |
+
INFERMATICAI: 'infermaticai',
|
| 231 |
+
DREAMGEN: 'dreamgen',
|
| 232 |
+
OPENROUTER: 'openrouter',
|
| 233 |
+
FEATHERLESS: 'featherless',
|
| 234 |
+
HUGGINGFACE: 'huggingface',
|
| 235 |
+
GENERIC: 'generic',
|
| 236 |
+
};
|
| 237 |
+
|
| 238 |
+
export const INFERMATICAI_KEYS = [
|
| 239 |
+
'model',
|
| 240 |
+
'prompt',
|
| 241 |
+
'max_tokens',
|
| 242 |
+
'temperature',
|
| 243 |
+
'top_p',
|
| 244 |
+
'top_k',
|
| 245 |
+
'repetition_penalty',
|
| 246 |
+
'stream',
|
| 247 |
+
'stop',
|
| 248 |
+
'presence_penalty',
|
| 249 |
+
'frequency_penalty',
|
| 250 |
+
'min_p',
|
| 251 |
+
'seed',
|
| 252 |
+
'ignore_eos',
|
| 253 |
+
'n',
|
| 254 |
+
'best_of',
|
| 255 |
+
'min_tokens',
|
| 256 |
+
'spaces_between_special_tokens',
|
| 257 |
+
'skip_special_tokens',
|
| 258 |
+
'logprobs',
|
| 259 |
+
];
|
| 260 |
+
|
| 261 |
+
export const FEATHERLESS_KEYS = [
|
| 262 |
+
'model',
|
| 263 |
+
'prompt',
|
| 264 |
+
'best_of',
|
| 265 |
+
'echo',
|
| 266 |
+
'frequency_penalty',
|
| 267 |
+
'logit_bias',
|
| 268 |
+
'logprobs',
|
| 269 |
+
'max_tokens',
|
| 270 |
+
'n',
|
| 271 |
+
'presence_penalty',
|
| 272 |
+
'seed',
|
| 273 |
+
'stop',
|
| 274 |
+
'stream',
|
| 275 |
+
'suffix',
|
| 276 |
+
'temperature',
|
| 277 |
+
'top_p',
|
| 278 |
+
'user',
|
| 279 |
+
|
| 280 |
+
'use_beam_search',
|
| 281 |
+
'top_k',
|
| 282 |
+
'min_p',
|
| 283 |
+
'repetition_penalty',
|
| 284 |
+
'length_penalty',
|
| 285 |
+
'early_stopping',
|
| 286 |
+
'stop_token_ids',
|
| 287 |
+
'ignore_eos',
|
| 288 |
+
'min_tokens',
|
| 289 |
+
'skip_special_tokens',
|
| 290 |
+
'spaces_between_special_tokens',
|
| 291 |
+
'truncate_prompt_tokens',
|
| 292 |
+
|
| 293 |
+
'include_stop_str_in_output',
|
| 294 |
+
'response_format',
|
| 295 |
+
'guided_json',
|
| 296 |
+
'guided_regex',
|
| 297 |
+
'guided_choice',
|
| 298 |
+
'guided_grammar',
|
| 299 |
+
'guided_decoding_backend',
|
| 300 |
+
'guided_whitespace_pattern',
|
| 301 |
+
];
|
| 302 |
+
|
| 303 |
+
// https://docs.together.ai/reference/completions
|
| 304 |
+
export const TOGETHERAI_KEYS = [
|
| 305 |
+
'model',
|
| 306 |
+
'prompt',
|
| 307 |
+
'max_tokens',
|
| 308 |
+
'temperature',
|
| 309 |
+
'top_p',
|
| 310 |
+
'top_k',
|
| 311 |
+
'repetition_penalty',
|
| 312 |
+
'min_p',
|
| 313 |
+
'presence_penalty',
|
| 314 |
+
'frequency_penalty',
|
| 315 |
+
'stream',
|
| 316 |
+
'stop',
|
| 317 |
+
];
|
| 318 |
+
|
| 319 |
+
// https://github.com/ollama/ollama/blob/main/docs/api.md#request-8
|
| 320 |
+
export const OLLAMA_KEYS = [
|
| 321 |
+
'num_predict',
|
| 322 |
+
'num_ctx',
|
| 323 |
+
'num_batch',
|
| 324 |
+
'stop',
|
| 325 |
+
'temperature',
|
| 326 |
+
'repeat_penalty',
|
| 327 |
+
'presence_penalty',
|
| 328 |
+
'frequency_penalty',
|
| 329 |
+
'top_k',
|
| 330 |
+
'top_p',
|
| 331 |
+
'tfs_z',
|
| 332 |
+
'typical_p',
|
| 333 |
+
'seed',
|
| 334 |
+
'repeat_last_n',
|
| 335 |
+
'min_p',
|
| 336 |
+
];
|
| 337 |
+
|
| 338 |
+
// https://platform.openai.com/docs/api-reference/completions
|
| 339 |
+
export const OPENAI_KEYS = [
|
| 340 |
+
'model',
|
| 341 |
+
'prompt',
|
| 342 |
+
'stream',
|
| 343 |
+
'temperature',
|
| 344 |
+
'top_p',
|
| 345 |
+
'frequency_penalty',
|
| 346 |
+
'presence_penalty',
|
| 347 |
+
'stop',
|
| 348 |
+
'seed',
|
| 349 |
+
'logit_bias',
|
| 350 |
+
'logprobs',
|
| 351 |
+
'max_tokens',
|
| 352 |
+
'n',
|
| 353 |
+
'best_of',
|
| 354 |
+
];
|
| 355 |
+
|
| 356 |
+
export const AVATAR_WIDTH = 512;
|
| 357 |
+
export const AVATAR_HEIGHT = 768;
|
| 358 |
+
export const DEFAULT_AVATAR_PATH = './public/img/ai4.png';
|
| 359 |
+
|
| 360 |
+
export const OPENROUTER_HEADERS = {
|
| 361 |
+
'HTTP-Referer': 'https://tavernintern.app',
|
| 362 |
+
'X-Title': 'TavernIntern',
|
| 363 |
+
};
|
| 364 |
+
|
| 365 |
+
export const AIMLAPI_HEADERS = {
|
| 366 |
+
'HTTP-Referer': 'https://tavernintern.app',
|
| 367 |
+
'X-Title': 'TavernIntern',
|
| 368 |
+
};
|
| 369 |
+
|
| 370 |
+
export const FEATHERLESS_HEADERS = {
|
| 371 |
+
'HTTP-Referer': 'https://tavernintern.app',
|
| 372 |
+
'X-Title': 'TavernIntern',
|
| 373 |
+
};
|
| 374 |
+
|
| 375 |
+
export const OPENROUTER_KEYS = [
|
| 376 |
+
'max_tokens',
|
| 377 |
+
'temperature',
|
| 378 |
+
'top_k',
|
| 379 |
+
'top_p',
|
| 380 |
+
'presence_penalty',
|
| 381 |
+
'frequency_penalty',
|
| 382 |
+
'repetition_penalty',
|
| 383 |
+
'min_p',
|
| 384 |
+
'top_a',
|
| 385 |
+
'seed',
|
| 386 |
+
'logit_bias',
|
| 387 |
+
'model',
|
| 388 |
+
'stream',
|
| 389 |
+
'prompt',
|
| 390 |
+
'stop',
|
| 391 |
+
'provider',
|
| 392 |
+
'include_reasoning',
|
| 393 |
+
];
|
| 394 |
+
|
| 395 |
+
// https://github.com/vllm-project/vllm/blob/0f8a91401c89ac0a8018def3756829611b57727f/vllm/entrypoints/openai/protocol.py#L220
|
| 396 |
+
export const VLLM_KEYS = [
|
| 397 |
+
'model',
|
| 398 |
+
'prompt',
|
| 399 |
+
'best_of',
|
| 400 |
+
'echo',
|
| 401 |
+
'frequency_penalty',
|
| 402 |
+
'logit_bias',
|
| 403 |
+
'logprobs',
|
| 404 |
+
'max_tokens',
|
| 405 |
+
'n',
|
| 406 |
+
'presence_penalty',
|
| 407 |
+
'seed',
|
| 408 |
+
'stop',
|
| 409 |
+
'stream',
|
| 410 |
+
'suffix',
|
| 411 |
+
'temperature',
|
| 412 |
+
'top_p',
|
| 413 |
+
'user',
|
| 414 |
+
|
| 415 |
+
'use_beam_search',
|
| 416 |
+
'top_k',
|
| 417 |
+
'min_p',
|
| 418 |
+
'repetition_penalty',
|
| 419 |
+
'length_penalty',
|
| 420 |
+
'early_stopping',
|
| 421 |
+
'stop_token_ids',
|
| 422 |
+
'ignore_eos',
|
| 423 |
+
'min_tokens',
|
| 424 |
+
'skip_special_tokens',
|
| 425 |
+
'spaces_between_special_tokens',
|
| 426 |
+
'truncate_prompt_tokens',
|
| 427 |
+
|
| 428 |
+
'include_stop_str_in_output',
|
| 429 |
+
'response_format',
|
| 430 |
+
'guided_json',
|
| 431 |
+
'guided_regex',
|
| 432 |
+
'guided_choice',
|
| 433 |
+
'guided_grammar',
|
| 434 |
+
'guided_decoding_backend',
|
| 435 |
+
'guided_whitespace_pattern',
|
| 436 |
+
];
|
| 437 |
+
|
| 438 |
+
export const AZURE_OPENAI_KEYS = [
|
| 439 |
+
'messages',
|
| 440 |
+
'temperature',
|
| 441 |
+
'frequency_penalty',
|
| 442 |
+
'presence_penalty',
|
| 443 |
+
'top_p',
|
| 444 |
+
'max_tokens',
|
| 445 |
+
'max_completion_tokens',
|
| 446 |
+
'stream',
|
| 447 |
+
'logit_bias',
|
| 448 |
+
'stop',
|
| 449 |
+
'n',
|
| 450 |
+
'logprobs',
|
| 451 |
+
'seed',
|
| 452 |
+
'tools',
|
| 453 |
+
'tool_choice',
|
| 454 |
+
'reasoning_effort',
|
| 455 |
+
];
|
| 456 |
+
|
| 457 |
+
export const OPENAI_VERBOSITY_MODELS = /^gpt-5/;
|
| 458 |
+
|
| 459 |
+
export const OPENAI_REASONING_EFFORT_MODELS = [
|
| 460 |
+
'o1',
|
| 461 |
+
'o3-mini',
|
| 462 |
+
'o3-mini-2025-01-31',
|
| 463 |
+
'o4-mini',
|
| 464 |
+
'o4-mini-2025-04-16',
|
| 465 |
+
'o3',
|
| 466 |
+
'o3-2025-04-16',
|
| 467 |
+
'gpt-5',
|
| 468 |
+
'gpt-5-2025-08-07',
|
| 469 |
+
'gpt-5-mini',
|
| 470 |
+
'gpt-5-mini-2025-08-07',
|
| 471 |
+
'gpt-5-nano',
|
| 472 |
+
'gpt-5-nano-2025-08-07',
|
| 473 |
+
'gpt-5.1',
|
| 474 |
+
'gpt-5.1-2025-11-13',
|
| 475 |
+
'gpt-5.1-chat-latest',
|
| 476 |
+
'gpt-5.2',
|
| 477 |
+
'gpt-5.2-2025-12-11',
|
| 478 |
+
'gpt-5.2-chat-latest',
|
| 479 |
+
];
|
| 480 |
+
|
| 481 |
+
export const OPENAI_REASONING_EFFORT_MAP = {
|
| 482 |
+
min: 'minimal',
|
| 483 |
+
};
|
| 484 |
+
|
| 485 |
+
export const LOG_LEVELS = {
|
| 486 |
+
DEBUG: 0,
|
| 487 |
+
INFO: 1,
|
| 488 |
+
WARN: 2,
|
| 489 |
+
ERROR: 3,
|
| 490 |
+
};
|
| 491 |
+
|
| 492 |
+
/**
|
| 493 |
+
* An array of supported media file extensions.
|
| 494 |
+
* This is used to validate file uploads and ensure that only supported media types are processed.
|
| 495 |
+
*/
|
| 496 |
+
export const MEDIA_EXTENSIONS = [
|
| 497 |
+
'bmp',
|
| 498 |
+
'png',
|
| 499 |
+
'jpg',
|
| 500 |
+
'webp',
|
| 501 |
+
'jpeg',
|
| 502 |
+
'jfif',
|
| 503 |
+
'gif',
|
| 504 |
+
'mp4',
|
| 505 |
+
'avi',
|
| 506 |
+
'mov',
|
| 507 |
+
'wmv',
|
| 508 |
+
'flv',
|
| 509 |
+
'webm',
|
| 510 |
+
'3gp',
|
| 511 |
+
'mkv',
|
| 512 |
+
'mpg',
|
| 513 |
+
'mp3',
|
| 514 |
+
'wav',
|
| 515 |
+
'ogg',
|
| 516 |
+
'flac',
|
| 517 |
+
'aac',
|
| 518 |
+
'm4a',
|
| 519 |
+
'aiff',
|
| 520 |
+
];
|
| 521 |
+
|
| 522 |
+
/**
|
| 523 |
+
* Bitwise flag-style media request types.
|
| 524 |
+
*/
|
| 525 |
+
export const MEDIA_REQUEST_TYPE = {
|
| 526 |
+
IMAGE: 0b001,
|
| 527 |
+
VIDEO: 0b010,
|
| 528 |
+
AUDIO: 0b100,
|
| 529 |
+
};
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
export const ZAI_ENDPOINT = {
|
| 533 |
+
COMMON: 'common',
|
| 534 |
+
CODING: 'coding',
|
| 535 |
+
};
|
src/electron/Start.bat
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
pushd %~dp0
|
| 3 |
+
call npm install --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev
|
| 4 |
+
npm run start server.js %*
|
| 5 |
+
pause
|
| 6 |
+
popd
|
src/electron/index.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { app, BrowserWindow } from 'electron';
|
| 2 |
+
import path from 'path';
|
| 3 |
+
import { fileURLToPath } from 'url';
|
| 4 |
+
import yargs from 'yargs';
|
| 5 |
+
import { serverEvents, EVENT_NAMES } from '../server-events.js';
|
| 6 |
+
|
| 7 |
+
const cliArguments = yargs(process.argv)
|
| 8 |
+
.usage('Usage: <your-start-script> [options]')
|
| 9 |
+
.option('width', {
|
| 10 |
+
type: 'number',
|
| 11 |
+
default: 800,
|
| 12 |
+
describe: 'The width of the window',
|
| 13 |
+
})
|
| 14 |
+
.option('height', {
|
| 15 |
+
type: 'number',
|
| 16 |
+
default: 600,
|
| 17 |
+
describe: 'The height of the window',
|
| 18 |
+
})
|
| 19 |
+
.parseSync();
|
| 20 |
+
|
| 21 |
+
/** @type {string} The URL to load in the window. */
|
| 22 |
+
let appUrl;
|
| 23 |
+
|
| 24 |
+
function createTavernInternWindow() {
|
| 25 |
+
if (!appUrl) {
|
| 26 |
+
console.error('The server has not started yet.');
|
| 27 |
+
return;
|
| 28 |
+
}
|
| 29 |
+
new BrowserWindow({
|
| 30 |
+
height: cliArguments.height,
|
| 31 |
+
width: cliArguments.width,
|
| 32 |
+
}).loadURL(appUrl);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
function startServer() {
|
| 36 |
+
return new Promise((_resolve, _reject) => {
|
| 37 |
+
serverEvents.addListener(EVENT_NAMES.SERVER_STARTED, ({ url }) => {
|
| 38 |
+
appUrl = url.toString();
|
| 39 |
+
createTavernInternWindow();
|
| 40 |
+
});
|
| 41 |
+
const sillyTavernRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
| 42 |
+
process.chdir(sillyTavernRoot);
|
| 43 |
+
|
| 44 |
+
import('../server-global.js');
|
| 45 |
+
});
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
app.whenReady().then(() => {
|
| 49 |
+
app.on('activate', () => {
|
| 50 |
+
if (BrowserWindow.getAllWindows().length === 0) {
|
| 51 |
+
createTavernInternWindow();
|
| 52 |
+
}
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
startServer();
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
app.on('window-all-closed', () => {
|
| 59 |
+
if (process.platform !== 'darwin') {
|
| 60 |
+
app.quit();
|
| 61 |
+
}
|
| 62 |
+
});
|
src/electron/package-lock.json
ADDED
|
@@ -0,0 +1,802 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "sillytavern-electron",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"lockfileVersion": 3,
|
| 5 |
+
"requires": true,
|
| 6 |
+
"packages": {
|
| 7 |
+
"": {
|
| 8 |
+
"name": "sillytavern-electron",
|
| 9 |
+
"version": "1.0.0",
|
| 10 |
+
"license": "AGPL-3.0",
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"electron": "^35.0.0"
|
| 13 |
+
}
|
| 14 |
+
},
|
| 15 |
+
"node_modules/@electron/get": {
|
| 16 |
+
"version": "2.0.3",
|
| 17 |
+
"resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
|
| 18 |
+
"integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
|
| 19 |
+
"license": "MIT",
|
| 20 |
+
"dependencies": {
|
| 21 |
+
"debug": "^4.1.1",
|
| 22 |
+
"env-paths": "^2.2.0",
|
| 23 |
+
"fs-extra": "^8.1.0",
|
| 24 |
+
"got": "^11.8.5",
|
| 25 |
+
"progress": "^2.0.3",
|
| 26 |
+
"semver": "^6.2.0",
|
| 27 |
+
"sumchecker": "^3.0.1"
|
| 28 |
+
},
|
| 29 |
+
"engines": {
|
| 30 |
+
"node": ">=12"
|
| 31 |
+
},
|
| 32 |
+
"optionalDependencies": {
|
| 33 |
+
"global-agent": "^3.0.0"
|
| 34 |
+
}
|
| 35 |
+
},
|
| 36 |
+
"node_modules/@sindresorhus/is": {
|
| 37 |
+
"version": "4.6.0",
|
| 38 |
+
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
|
| 39 |
+
"integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==",
|
| 40 |
+
"license": "MIT",
|
| 41 |
+
"engines": {
|
| 42 |
+
"node": ">=10"
|
| 43 |
+
},
|
| 44 |
+
"funding": {
|
| 45 |
+
"url": "https://github.com/sindresorhus/is?sponsor=1"
|
| 46 |
+
}
|
| 47 |
+
},
|
| 48 |
+
"node_modules/@szmarczak/http-timer": {
|
| 49 |
+
"version": "4.0.6",
|
| 50 |
+
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
|
| 51 |
+
"integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==",
|
| 52 |
+
"license": "MIT",
|
| 53 |
+
"dependencies": {
|
| 54 |
+
"defer-to-connect": "^2.0.0"
|
| 55 |
+
},
|
| 56 |
+
"engines": {
|
| 57 |
+
"node": ">=10"
|
| 58 |
+
}
|
| 59 |
+
},
|
| 60 |
+
"node_modules/@types/cacheable-request": {
|
| 61 |
+
"version": "6.0.3",
|
| 62 |
+
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
|
| 63 |
+
"integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==",
|
| 64 |
+
"license": "MIT",
|
| 65 |
+
"dependencies": {
|
| 66 |
+
"@types/http-cache-semantics": "*",
|
| 67 |
+
"@types/keyv": "^3.1.4",
|
| 68 |
+
"@types/node": "*",
|
| 69 |
+
"@types/responselike": "^1.0.0"
|
| 70 |
+
}
|
| 71 |
+
},
|
| 72 |
+
"node_modules/@types/http-cache-semantics": {
|
| 73 |
+
"version": "4.0.4",
|
| 74 |
+
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
|
| 75 |
+
"integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==",
|
| 76 |
+
"license": "MIT"
|
| 77 |
+
},
|
| 78 |
+
"node_modules/@types/keyv": {
|
| 79 |
+
"version": "3.1.4",
|
| 80 |
+
"resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
|
| 81 |
+
"integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==",
|
| 82 |
+
"license": "MIT",
|
| 83 |
+
"dependencies": {
|
| 84 |
+
"@types/node": "*"
|
| 85 |
+
}
|
| 86 |
+
},
|
| 87 |
+
"node_modules/@types/node": {
|
| 88 |
+
"version": "22.13.9",
|
| 89 |
+
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz",
|
| 90 |
+
"integrity": "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==",
|
| 91 |
+
"license": "MIT",
|
| 92 |
+
"dependencies": {
|
| 93 |
+
"undici-types": "~6.20.0"
|
| 94 |
+
}
|
| 95 |
+
},
|
| 96 |
+
"node_modules/@types/responselike": {
|
| 97 |
+
"version": "1.0.3",
|
| 98 |
+
"resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz",
|
| 99 |
+
"integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==",
|
| 100 |
+
"license": "MIT",
|
| 101 |
+
"dependencies": {
|
| 102 |
+
"@types/node": "*"
|
| 103 |
+
}
|
| 104 |
+
},
|
| 105 |
+
"node_modules/@types/yauzl": {
|
| 106 |
+
"version": "2.10.3",
|
| 107 |
+
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
| 108 |
+
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
|
| 109 |
+
"license": "MIT",
|
| 110 |
+
"optional": true,
|
| 111 |
+
"dependencies": {
|
| 112 |
+
"@types/node": "*"
|
| 113 |
+
}
|
| 114 |
+
},
|
| 115 |
+
"node_modules/boolean": {
|
| 116 |
+
"version": "3.2.0",
|
| 117 |
+
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
|
| 118 |
+
"integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
|
| 119 |
+
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
| 120 |
+
"license": "MIT",
|
| 121 |
+
"optional": true
|
| 122 |
+
},
|
| 123 |
+
"node_modules/buffer-crc32": {
|
| 124 |
+
"version": "0.2.13",
|
| 125 |
+
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
| 126 |
+
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
|
| 127 |
+
"license": "MIT",
|
| 128 |
+
"engines": {
|
| 129 |
+
"node": "*"
|
| 130 |
+
}
|
| 131 |
+
},
|
| 132 |
+
"node_modules/cacheable-lookup": {
|
| 133 |
+
"version": "5.0.4",
|
| 134 |
+
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
|
| 135 |
+
"integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==",
|
| 136 |
+
"license": "MIT",
|
| 137 |
+
"engines": {
|
| 138 |
+
"node": ">=10.6.0"
|
| 139 |
+
}
|
| 140 |
+
},
|
| 141 |
+
"node_modules/cacheable-request": {
|
| 142 |
+
"version": "7.0.4",
|
| 143 |
+
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz",
|
| 144 |
+
"integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==",
|
| 145 |
+
"license": "MIT",
|
| 146 |
+
"dependencies": {
|
| 147 |
+
"clone-response": "^1.0.2",
|
| 148 |
+
"get-stream": "^5.1.0",
|
| 149 |
+
"http-cache-semantics": "^4.0.0",
|
| 150 |
+
"keyv": "^4.0.0",
|
| 151 |
+
"lowercase-keys": "^2.0.0",
|
| 152 |
+
"normalize-url": "^6.0.1",
|
| 153 |
+
"responselike": "^2.0.0"
|
| 154 |
+
},
|
| 155 |
+
"engines": {
|
| 156 |
+
"node": ">=8"
|
| 157 |
+
}
|
| 158 |
+
},
|
| 159 |
+
"node_modules/clone-response": {
|
| 160 |
+
"version": "1.0.3",
|
| 161 |
+
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
|
| 162 |
+
"integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==",
|
| 163 |
+
"license": "MIT",
|
| 164 |
+
"dependencies": {
|
| 165 |
+
"mimic-response": "^1.0.0"
|
| 166 |
+
},
|
| 167 |
+
"funding": {
|
| 168 |
+
"url": "https://github.com/sponsors/sindresorhus"
|
| 169 |
+
}
|
| 170 |
+
},
|
| 171 |
+
"node_modules/debug": {
|
| 172 |
+
"version": "4.4.0",
|
| 173 |
+
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
| 174 |
+
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
|
| 175 |
+
"license": "MIT",
|
| 176 |
+
"dependencies": {
|
| 177 |
+
"ms": "^2.1.3"
|
| 178 |
+
},
|
| 179 |
+
"engines": {
|
| 180 |
+
"node": ">=6.0"
|
| 181 |
+
},
|
| 182 |
+
"peerDependenciesMeta": {
|
| 183 |
+
"supports-color": {
|
| 184 |
+
"optional": true
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
},
|
| 188 |
+
"node_modules/decompress-response": {
|
| 189 |
+
"version": "6.0.0",
|
| 190 |
+
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
| 191 |
+
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
| 192 |
+
"license": "MIT",
|
| 193 |
+
"dependencies": {
|
| 194 |
+
"mimic-response": "^3.1.0"
|
| 195 |
+
},
|
| 196 |
+
"engines": {
|
| 197 |
+
"node": ">=10"
|
| 198 |
+
},
|
| 199 |
+
"funding": {
|
| 200 |
+
"url": "https://github.com/sponsors/sindresorhus"
|
| 201 |
+
}
|
| 202 |
+
},
|
| 203 |
+
"node_modules/decompress-response/node_modules/mimic-response": {
|
| 204 |
+
"version": "3.1.0",
|
| 205 |
+
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
| 206 |
+
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
| 207 |
+
"license": "MIT",
|
| 208 |
+
"engines": {
|
| 209 |
+
"node": ">=10"
|
| 210 |
+
},
|
| 211 |
+
"funding": {
|
| 212 |
+
"url": "https://github.com/sponsors/sindresorhus"
|
| 213 |
+
}
|
| 214 |
+
},
|
| 215 |
+
"node_modules/defer-to-connect": {
|
| 216 |
+
"version": "2.0.1",
|
| 217 |
+
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
|
| 218 |
+
"integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
|
| 219 |
+
"license": "MIT",
|
| 220 |
+
"engines": {
|
| 221 |
+
"node": ">=10"
|
| 222 |
+
}
|
| 223 |
+
},
|
| 224 |
+
"node_modules/define-data-property": {
|
| 225 |
+
"version": "1.1.4",
|
| 226 |
+
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
| 227 |
+
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
| 228 |
+
"license": "MIT",
|
| 229 |
+
"optional": true,
|
| 230 |
+
"dependencies": {
|
| 231 |
+
"es-define-property": "^1.0.0",
|
| 232 |
+
"es-errors": "^1.3.0",
|
| 233 |
+
"gopd": "^1.0.1"
|
| 234 |
+
},
|
| 235 |
+
"engines": {
|
| 236 |
+
"node": ">= 0.4"
|
| 237 |
+
},
|
| 238 |
+
"funding": {
|
| 239 |
+
"url": "https://github.com/sponsors/ljharb"
|
| 240 |
+
}
|
| 241 |
+
},
|
| 242 |
+
"node_modules/define-properties": {
|
| 243 |
+
"version": "1.2.1",
|
| 244 |
+
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
|
| 245 |
+
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
|
| 246 |
+
"license": "MIT",
|
| 247 |
+
"optional": true,
|
| 248 |
+
"dependencies": {
|
| 249 |
+
"define-data-property": "^1.0.1",
|
| 250 |
+
"has-property-descriptors": "^1.0.0",
|
| 251 |
+
"object-keys": "^1.1.1"
|
| 252 |
+
},
|
| 253 |
+
"engines": {
|
| 254 |
+
"node": ">= 0.4"
|
| 255 |
+
},
|
| 256 |
+
"funding": {
|
| 257 |
+
"url": "https://github.com/sponsors/ljharb"
|
| 258 |
+
}
|
| 259 |
+
},
|
| 260 |
+
"node_modules/detect-node": {
|
| 261 |
+
"version": "2.1.0",
|
| 262 |
+
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
|
| 263 |
+
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
|
| 264 |
+
"license": "MIT",
|
| 265 |
+
"optional": true
|
| 266 |
+
},
|
| 267 |
+
"node_modules/electron": {
|
| 268 |
+
"version": "35.7.5",
|
| 269 |
+
"resolved": "https://registry.npmjs.org/electron/-/electron-35.7.5.tgz",
|
| 270 |
+
"integrity": "sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw==",
|
| 271 |
+
"hasInstallScript": true,
|
| 272 |
+
"license": "MIT",
|
| 273 |
+
"dependencies": {
|
| 274 |
+
"@electron/get": "^2.0.0",
|
| 275 |
+
"@types/node": "^22.7.7",
|
| 276 |
+
"extract-zip": "^2.0.1"
|
| 277 |
+
},
|
| 278 |
+
"bin": {
|
| 279 |
+
"electron": "cli.js"
|
| 280 |
+
},
|
| 281 |
+
"engines": {
|
| 282 |
+
"node": ">= 12.20.55"
|
| 283 |
+
}
|
| 284 |
+
},
|
| 285 |
+
"node_modules/end-of-stream": {
|
| 286 |
+
"version": "1.4.4",
|
| 287 |
+
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
|
| 288 |
+
"integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
|
| 289 |
+
"license": "MIT",
|
| 290 |
+
"dependencies": {
|
| 291 |
+
"once": "^1.4.0"
|
| 292 |
+
}
|
| 293 |
+
},
|
| 294 |
+
"node_modules/env-paths": {
|
| 295 |
+
"version": "2.2.1",
|
| 296 |
+
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
| 297 |
+
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
|
| 298 |
+
"license": "MIT",
|
| 299 |
+
"engines": {
|
| 300 |
+
"node": ">=6"
|
| 301 |
+
}
|
| 302 |
+
},
|
| 303 |
+
"node_modules/es-define-property": {
|
| 304 |
+
"version": "1.0.1",
|
| 305 |
+
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
| 306 |
+
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
| 307 |
+
"license": "MIT",
|
| 308 |
+
"optional": true,
|
| 309 |
+
"engines": {
|
| 310 |
+
"node": ">= 0.4"
|
| 311 |
+
}
|
| 312 |
+
},
|
| 313 |
+
"node_modules/es-errors": {
|
| 314 |
+
"version": "1.3.0",
|
| 315 |
+
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
| 316 |
+
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
| 317 |
+
"license": "MIT",
|
| 318 |
+
"optional": true,
|
| 319 |
+
"engines": {
|
| 320 |
+
"node": ">= 0.4"
|
| 321 |
+
}
|
| 322 |
+
},
|
| 323 |
+
"node_modules/es6-error": {
|
| 324 |
+
"version": "4.1.1",
|
| 325 |
+
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
|
| 326 |
+
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
|
| 327 |
+
"license": "MIT",
|
| 328 |
+
"optional": true
|
| 329 |
+
},
|
| 330 |
+
"node_modules/escape-string-regexp": {
|
| 331 |
+
"version": "4.0.0",
|
| 332 |
+
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
| 333 |
+
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
| 334 |
+
"license": "MIT",
|
| 335 |
+
"optional": true,
|
| 336 |
+
"engines": {
|
| 337 |
+
"node": ">=10"
|
| 338 |
+
},
|
| 339 |
+
"funding": {
|
| 340 |
+
"url": "https://github.com/sponsors/sindresorhus"
|
| 341 |
+
}
|
| 342 |
+
},
|
| 343 |
+
"node_modules/extract-zip": {
|
| 344 |
+
"version": "2.0.1",
|
| 345 |
+
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
|
| 346 |
+
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
|
| 347 |
+
"license": "BSD-2-Clause",
|
| 348 |
+
"dependencies": {
|
| 349 |
+
"debug": "^4.1.1",
|
| 350 |
+
"get-stream": "^5.1.0",
|
| 351 |
+
"yauzl": "^2.10.0"
|
| 352 |
+
},
|
| 353 |
+
"bin": {
|
| 354 |
+
"extract-zip": "cli.js"
|
| 355 |
+
},
|
| 356 |
+
"engines": {
|
| 357 |
+
"node": ">= 10.17.0"
|
| 358 |
+
},
|
| 359 |
+
"optionalDependencies": {
|
| 360 |
+
"@types/yauzl": "^2.9.1"
|
| 361 |
+
}
|
| 362 |
+
},
|
| 363 |
+
"node_modules/fd-slicer": {
|
| 364 |
+
"version": "1.1.0",
|
| 365 |
+
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
| 366 |
+
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
|
| 367 |
+
"license": "MIT",
|
| 368 |
+
"dependencies": {
|
| 369 |
+
"pend": "~1.2.0"
|
| 370 |
+
}
|
| 371 |
+
},
|
| 372 |
+
"node_modules/fs-extra": {
|
| 373 |
+
"version": "8.1.0",
|
| 374 |
+
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
|
| 375 |
+
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
|
| 376 |
+
"license": "MIT",
|
| 377 |
+
"dependencies": {
|
| 378 |
+
"graceful-fs": "^4.2.0",
|
| 379 |
+
"jsonfile": "^4.0.0",
|
| 380 |
+
"universalify": "^0.1.0"
|
| 381 |
+
},
|
| 382 |
+
"engines": {
|
| 383 |
+
"node": ">=6 <7 || >=8"
|
| 384 |
+
}
|
| 385 |
+
},
|
| 386 |
+
"node_modules/get-stream": {
|
| 387 |
+
"version": "5.2.0",
|
| 388 |
+
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
|
| 389 |
+
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
|
| 390 |
+
"license": "MIT",
|
| 391 |
+
"dependencies": {
|
| 392 |
+
"pump": "^3.0.0"
|
| 393 |
+
},
|
| 394 |
+
"engines": {
|
| 395 |
+
"node": ">=8"
|
| 396 |
+
},
|
| 397 |
+
"funding": {
|
| 398 |
+
"url": "https://github.com/sponsors/sindresorhus"
|
| 399 |
+
}
|
| 400 |
+
},
|
| 401 |
+
"node_modules/global-agent": {
|
| 402 |
+
"version": "3.0.0",
|
| 403 |
+
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
|
| 404 |
+
"integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
|
| 405 |
+
"license": "BSD-3-Clause",
|
| 406 |
+
"optional": true,
|
| 407 |
+
"dependencies": {
|
| 408 |
+
"boolean": "^3.0.1",
|
| 409 |
+
"es6-error": "^4.1.1",
|
| 410 |
+
"matcher": "^3.0.0",
|
| 411 |
+
"roarr": "^2.15.3",
|
| 412 |
+
"semver": "^7.3.2",
|
| 413 |
+
"serialize-error": "^7.0.1"
|
| 414 |
+
},
|
| 415 |
+
"engines": {
|
| 416 |
+
"node": ">=10.0"
|
| 417 |
+
}
|
| 418 |
+
},
|
| 419 |
+
"node_modules/global-agent/node_modules/semver": {
|
| 420 |
+
"version": "7.7.1",
|
| 421 |
+
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
|
| 422 |
+
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
|
| 423 |
+
"license": "ISC",
|
| 424 |
+
"optional": true,
|
| 425 |
+
"bin": {
|
| 426 |
+
"semver": "bin/semver.js"
|
| 427 |
+
},
|
| 428 |
+
"engines": {
|
| 429 |
+
"node": ">=10"
|
| 430 |
+
}
|
| 431 |
+
},
|
| 432 |
+
"node_modules/globalthis": {
|
| 433 |
+
"version": "1.0.4",
|
| 434 |
+
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
|
| 435 |
+
"integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
|
| 436 |
+
"license": "MIT",
|
| 437 |
+
"optional": true,
|
| 438 |
+
"dependencies": {
|
| 439 |
+
"define-properties": "^1.2.1",
|
| 440 |
+
"gopd": "^1.0.1"
|
| 441 |
+
},
|
| 442 |
+
"engines": {
|
| 443 |
+
"node": ">= 0.4"
|
| 444 |
+
},
|
| 445 |
+
"funding": {
|
| 446 |
+
"url": "https://github.com/sponsors/ljharb"
|
| 447 |
+
}
|
| 448 |
+
},
|
| 449 |
+
"node_modules/gopd": {
|
| 450 |
+
"version": "1.2.0",
|
| 451 |
+
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
| 452 |
+
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
| 453 |
+
"license": "MIT",
|
| 454 |
+
"optional": true,
|
| 455 |
+
"engines": {
|
| 456 |
+
"node": ">= 0.4"
|
| 457 |
+
},
|
| 458 |
+
"funding": {
|
| 459 |
+
"url": "https://github.com/sponsors/ljharb"
|
| 460 |
+
}
|
| 461 |
+
},
|
| 462 |
+
"node_modules/got": {
|
| 463 |
+
"version": "11.8.6",
|
| 464 |
+
"resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz",
|
| 465 |
+
"integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==",
|
| 466 |
+
"license": "MIT",
|
| 467 |
+
"dependencies": {
|
| 468 |
+
"@sindresorhus/is": "^4.0.0",
|
| 469 |
+
"@szmarczak/http-timer": "^4.0.5",
|
| 470 |
+
"@types/cacheable-request": "^6.0.1",
|
| 471 |
+
"@types/responselike": "^1.0.0",
|
| 472 |
+
"cacheable-lookup": "^5.0.3",
|
| 473 |
+
"cacheable-request": "^7.0.2",
|
| 474 |
+
"decompress-response": "^6.0.0",
|
| 475 |
+
"http2-wrapper": "^1.0.0-beta.5.2",
|
| 476 |
+
"lowercase-keys": "^2.0.0",
|
| 477 |
+
"p-cancelable": "^2.0.0",
|
| 478 |
+
"responselike": "^2.0.0"
|
| 479 |
+
},
|
| 480 |
+
"engines": {
|
| 481 |
+
"node": ">=10.19.0"
|
| 482 |
+
},
|
| 483 |
+
"funding": {
|
| 484 |
+
"url": "https://github.com/sindresorhus/got?sponsor=1"
|
| 485 |
+
}
|
| 486 |
+
},
|
| 487 |
+
"node_modules/graceful-fs": {
|
| 488 |
+
"version": "4.2.11",
|
| 489 |
+
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
| 490 |
+
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
| 491 |
+
"license": "ISC"
|
| 492 |
+
},
|
| 493 |
+
"node_modules/has-property-descriptors": {
|
| 494 |
+
"version": "1.0.2",
|
| 495 |
+
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
| 496 |
+
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
| 497 |
+
"license": "MIT",
|
| 498 |
+
"optional": true,
|
| 499 |
+
"dependencies": {
|
| 500 |
+
"es-define-property": "^1.0.0"
|
| 501 |
+
},
|
| 502 |
+
"funding": {
|
| 503 |
+
"url": "https://github.com/sponsors/ljharb"
|
| 504 |
+
}
|
| 505 |
+
},
|
| 506 |
+
"node_modules/http-cache-semantics": {
|
| 507 |
+
"version": "4.1.1",
|
| 508 |
+
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
|
| 509 |
+
"integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==",
|
| 510 |
+
"license": "BSD-2-Clause"
|
| 511 |
+
},
|
| 512 |
+
"node_modules/http2-wrapper": {
|
| 513 |
+
"version": "1.0.3",
|
| 514 |
+
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
|
| 515 |
+
"integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==",
|
| 516 |
+
"license": "MIT",
|
| 517 |
+
"dependencies": {
|
| 518 |
+
"quick-lru": "^5.1.1",
|
| 519 |
+
"resolve-alpn": "^1.0.0"
|
| 520 |
+
},
|
| 521 |
+
"engines": {
|
| 522 |
+
"node": ">=10.19.0"
|
| 523 |
+
}
|
| 524 |
+
},
|
| 525 |
+
"node_modules/json-buffer": {
|
| 526 |
+
"version": "3.0.1",
|
| 527 |
+
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
| 528 |
+
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
| 529 |
+
"license": "MIT"
|
| 530 |
+
},
|
| 531 |
+
"node_modules/json-stringify-safe": {
|
| 532 |
+
"version": "5.0.1",
|
| 533 |
+
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
|
| 534 |
+
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
|
| 535 |
+
"license": "ISC",
|
| 536 |
+
"optional": true
|
| 537 |
+
},
|
| 538 |
+
"node_modules/jsonfile": {
|
| 539 |
+
"version": "4.0.0",
|
| 540 |
+
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
|
| 541 |
+
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
| 542 |
+
"license": "MIT",
|
| 543 |
+
"optionalDependencies": {
|
| 544 |
+
"graceful-fs": "^4.1.6"
|
| 545 |
+
}
|
| 546 |
+
},
|
| 547 |
+
"node_modules/keyv": {
|
| 548 |
+
"version": "4.5.4",
|
| 549 |
+
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
| 550 |
+
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
|
| 551 |
+
"license": "MIT",
|
| 552 |
+
"dependencies": {
|
| 553 |
+
"json-buffer": "3.0.1"
|
| 554 |
+
}
|
| 555 |
+
},
|
| 556 |
+
"node_modules/lowercase-keys": {
|
| 557 |
+
"version": "2.0.0",
|
| 558 |
+
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
|
| 559 |
+
"integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==",
|
| 560 |
+
"license": "MIT",
|
| 561 |
+
"engines": {
|
| 562 |
+
"node": ">=8"
|
| 563 |
+
}
|
| 564 |
+
},
|
| 565 |
+
"node_modules/matcher": {
|
| 566 |
+
"version": "3.0.0",
|
| 567 |
+
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
|
| 568 |
+
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
|
| 569 |
+
"license": "MIT",
|
| 570 |
+
"optional": true,
|
| 571 |
+
"dependencies": {
|
| 572 |
+
"escape-string-regexp": "^4.0.0"
|
| 573 |
+
},
|
| 574 |
+
"engines": {
|
| 575 |
+
"node": ">=10"
|
| 576 |
+
}
|
| 577 |
+
},
|
| 578 |
+
"node_modules/mimic-response": {
|
| 579 |
+
"version": "1.0.1",
|
| 580 |
+
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
|
| 581 |
+
"integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==",
|
| 582 |
+
"license": "MIT",
|
| 583 |
+
"engines": {
|
| 584 |
+
"node": ">=4"
|
| 585 |
+
}
|
| 586 |
+
},
|
| 587 |
+
"node_modules/ms": {
|
| 588 |
+
"version": "2.1.3",
|
| 589 |
+
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
| 590 |
+
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
| 591 |
+
"license": "MIT"
|
| 592 |
+
},
|
| 593 |
+
"node_modules/normalize-url": {
|
| 594 |
+
"version": "6.1.0",
|
| 595 |
+
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
|
| 596 |
+
"integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
|
| 597 |
+
"license": "MIT",
|
| 598 |
+
"engines": {
|
| 599 |
+
"node": ">=10"
|
| 600 |
+
},
|
| 601 |
+
"funding": {
|
| 602 |
+
"url": "https://github.com/sponsors/sindresorhus"
|
| 603 |
+
}
|
| 604 |
+
},
|
| 605 |
+
"node_modules/object-keys": {
|
| 606 |
+
"version": "1.1.1",
|
| 607 |
+
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
| 608 |
+
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
| 609 |
+
"license": "MIT",
|
| 610 |
+
"optional": true,
|
| 611 |
+
"engines": {
|
| 612 |
+
"node": ">= 0.4"
|
| 613 |
+
}
|
| 614 |
+
},
|
| 615 |
+
"node_modules/once": {
|
| 616 |
+
"version": "1.4.0",
|
| 617 |
+
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
| 618 |
+
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
| 619 |
+
"license": "ISC",
|
| 620 |
+
"dependencies": {
|
| 621 |
+
"wrappy": "1"
|
| 622 |
+
}
|
| 623 |
+
},
|
| 624 |
+
"node_modules/p-cancelable": {
|
| 625 |
+
"version": "2.1.1",
|
| 626 |
+
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
|
| 627 |
+
"integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
|
| 628 |
+
"license": "MIT",
|
| 629 |
+
"engines": {
|
| 630 |
+
"node": ">=8"
|
| 631 |
+
}
|
| 632 |
+
},
|
| 633 |
+
"node_modules/pend": {
|
| 634 |
+
"version": "1.2.0",
|
| 635 |
+
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
| 636 |
+
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
| 637 |
+
"license": "MIT"
|
| 638 |
+
},
|
| 639 |
+
"node_modules/progress": {
|
| 640 |
+
"version": "2.0.3",
|
| 641 |
+
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
| 642 |
+
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
| 643 |
+
"license": "MIT",
|
| 644 |
+
"engines": {
|
| 645 |
+
"node": ">=0.4.0"
|
| 646 |
+
}
|
| 647 |
+
},
|
| 648 |
+
"node_modules/pump": {
|
| 649 |
+
"version": "3.0.2",
|
| 650 |
+
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz",
|
| 651 |
+
"integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==",
|
| 652 |
+
"license": "MIT",
|
| 653 |
+
"dependencies": {
|
| 654 |
+
"end-of-stream": "^1.1.0",
|
| 655 |
+
"once": "^1.3.1"
|
| 656 |
+
}
|
| 657 |
+
},
|
| 658 |
+
"node_modules/quick-lru": {
|
| 659 |
+
"version": "5.1.1",
|
| 660 |
+
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
|
| 661 |
+
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
|
| 662 |
+
"license": "MIT",
|
| 663 |
+
"engines": {
|
| 664 |
+
"node": ">=10"
|
| 665 |
+
},
|
| 666 |
+
"funding": {
|
| 667 |
+
"url": "https://github.com/sponsors/sindresorhus"
|
| 668 |
+
}
|
| 669 |
+
},
|
| 670 |
+
"node_modules/resolve-alpn": {
|
| 671 |
+
"version": "1.2.1",
|
| 672 |
+
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
|
| 673 |
+
"integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
|
| 674 |
+
"license": "MIT"
|
| 675 |
+
},
|
| 676 |
+
"node_modules/responselike": {
|
| 677 |
+
"version": "2.0.1",
|
| 678 |
+
"resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz",
|
| 679 |
+
"integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==",
|
| 680 |
+
"license": "MIT",
|
| 681 |
+
"dependencies": {
|
| 682 |
+
"lowercase-keys": "^2.0.0"
|
| 683 |
+
},
|
| 684 |
+
"funding": {
|
| 685 |
+
"url": "https://github.com/sponsors/sindresorhus"
|
| 686 |
+
}
|
| 687 |
+
},
|
| 688 |
+
"node_modules/roarr": {
|
| 689 |
+
"version": "2.15.4",
|
| 690 |
+
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
|
| 691 |
+
"integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
|
| 692 |
+
"license": "BSD-3-Clause",
|
| 693 |
+
"optional": true,
|
| 694 |
+
"dependencies": {
|
| 695 |
+
"boolean": "^3.0.1",
|
| 696 |
+
"detect-node": "^2.0.4",
|
| 697 |
+
"globalthis": "^1.0.1",
|
| 698 |
+
"json-stringify-safe": "^5.0.1",
|
| 699 |
+
"semver-compare": "^1.0.0",
|
| 700 |
+
"sprintf-js": "^1.1.2"
|
| 701 |
+
},
|
| 702 |
+
"engines": {
|
| 703 |
+
"node": ">=8.0"
|
| 704 |
+
}
|
| 705 |
+
},
|
| 706 |
+
"node_modules/semver": {
|
| 707 |
+
"version": "6.3.1",
|
| 708 |
+
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
| 709 |
+
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
| 710 |
+
"license": "ISC",
|
| 711 |
+
"bin": {
|
| 712 |
+
"semver": "bin/semver.js"
|
| 713 |
+
}
|
| 714 |
+
},
|
| 715 |
+
"node_modules/semver-compare": {
|
| 716 |
+
"version": "1.0.0",
|
| 717 |
+
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
|
| 718 |
+
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
|
| 719 |
+
"license": "MIT",
|
| 720 |
+
"optional": true
|
| 721 |
+
},
|
| 722 |
+
"node_modules/serialize-error": {
|
| 723 |
+
"version": "7.0.1",
|
| 724 |
+
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
|
| 725 |
+
"integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
|
| 726 |
+
"license": "MIT",
|
| 727 |
+
"optional": true,
|
| 728 |
+
"dependencies": {
|
| 729 |
+
"type-fest": "^0.13.1"
|
| 730 |
+
},
|
| 731 |
+
"engines": {
|
| 732 |
+
"node": ">=10"
|
| 733 |
+
},
|
| 734 |
+
"funding": {
|
| 735 |
+
"url": "https://github.com/sponsors/sindresorhus"
|
| 736 |
+
}
|
| 737 |
+
},
|
| 738 |
+
"node_modules/sprintf-js": {
|
| 739 |
+
"version": "1.1.3",
|
| 740 |
+
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
| 741 |
+
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
|
| 742 |
+
"license": "BSD-3-Clause",
|
| 743 |
+
"optional": true
|
| 744 |
+
},
|
| 745 |
+
"node_modules/sumchecker": {
|
| 746 |
+
"version": "3.0.1",
|
| 747 |
+
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
|
| 748 |
+
"integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
|
| 749 |
+
"license": "Apache-2.0",
|
| 750 |
+
"dependencies": {
|
| 751 |
+
"debug": "^4.1.0"
|
| 752 |
+
},
|
| 753 |
+
"engines": {
|
| 754 |
+
"node": ">= 8.0"
|
| 755 |
+
}
|
| 756 |
+
},
|
| 757 |
+
"node_modules/type-fest": {
|
| 758 |
+
"version": "0.13.1",
|
| 759 |
+
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
|
| 760 |
+
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
|
| 761 |
+
"license": "(MIT OR CC0-1.0)",
|
| 762 |
+
"optional": true,
|
| 763 |
+
"engines": {
|
| 764 |
+
"node": ">=10"
|
| 765 |
+
},
|
| 766 |
+
"funding": {
|
| 767 |
+
"url": "https://github.com/sponsors/sindresorhus"
|
| 768 |
+
}
|
| 769 |
+
},
|
| 770 |
+
"node_modules/undici-types": {
|
| 771 |
+
"version": "6.20.0",
|
| 772 |
+
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
| 773 |
+
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
|
| 774 |
+
"license": "MIT"
|
| 775 |
+
},
|
| 776 |
+
"node_modules/universalify": {
|
| 777 |
+
"version": "0.1.2",
|
| 778 |
+
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
|
| 779 |
+
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
|
| 780 |
+
"license": "MIT",
|
| 781 |
+
"engines": {
|
| 782 |
+
"node": ">= 4.0.0"
|
| 783 |
+
}
|
| 784 |
+
},
|
| 785 |
+
"node_modules/wrappy": {
|
| 786 |
+
"version": "1.0.2",
|
| 787 |
+
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
| 788 |
+
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
| 789 |
+
"license": "ISC"
|
| 790 |
+
},
|
| 791 |
+
"node_modules/yauzl": {
|
| 792 |
+
"version": "2.10.0",
|
| 793 |
+
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
|
| 794 |
+
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
|
| 795 |
+
"license": "MIT",
|
| 796 |
+
"dependencies": {
|
| 797 |
+
"buffer-crc32": "~0.2.3",
|
| 798 |
+
"fd-slicer": "~1.1.0"
|
| 799 |
+
}
|
| 800 |
+
}
|
| 801 |
+
}
|
| 802 |
+
}
|
src/electron/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "sillytavern-electron",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"description": "Electron server for SillyTavern",
|
| 5 |
+
"license": "AGPL-3.0",
|
| 6 |
+
"author": "",
|
| 7 |
+
"type": "module",
|
| 8 |
+
"main": "index.js",
|
| 9 |
+
"scripts": {
|
| 10 |
+
"test": "echo \"Error: no test specified\" && exit 1",
|
| 11 |
+
"start": "electron ."
|
| 12 |
+
},
|
| 13 |
+
"dependencies": {
|
| 14 |
+
"electron": "^35.0.0"
|
| 15 |
+
}
|
| 16 |
+
}
|
src/electron/start.sh
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
|
| 3 |
+
# Make sure pwd is the directory of the script
|
| 4 |
+
cd "$(dirname "$0")"
|
| 5 |
+
|
| 6 |
+
echo "Assuming nodejs and npm is already installed. If you haven't installed them already, do so now"
|
| 7 |
+
echo "Installing Electron Wrapper's Node Modules..."
|
| 8 |
+
npm i --no-save --no-audit --no-fund --loglevel=error --no-progress --omit=dev
|
| 9 |
+
|
| 10 |
+
echo "Starting Electron Wrapper..."
|
| 11 |
+
npm run start -- "$@"
|
src/endpoints/anthropic.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fetch from 'node-fetch';
|
| 2 |
+
import express from 'express';
|
| 3 |
+
|
| 4 |
+
import { readSecret, SECRET_KEYS } from './secrets.js';
|
| 5 |
+
|
| 6 |
+
export const router = express.Router();
|
| 7 |
+
|
| 8 |
+
router.post('/caption-image', async (request, response) => {
|
| 9 |
+
try {
|
| 10 |
+
const mimeType = request.body.image.split(';')[0].split(':')[1];
|
| 11 |
+
const base64Data = request.body.image.split(',')[1];
|
| 12 |
+
const baseUrl = request.body.reverse_proxy ? request.body.reverse_proxy : 'https://api.anthropic.com/v1';
|
| 13 |
+
const url = `${baseUrl}/messages`;
|
| 14 |
+
const body = {
|
| 15 |
+
model: request.body.model,
|
| 16 |
+
messages: [
|
| 17 |
+
{
|
| 18 |
+
'role': 'user', 'content': [
|
| 19 |
+
{
|
| 20 |
+
'type': 'image',
|
| 21 |
+
'source': {
|
| 22 |
+
'type': 'base64',
|
| 23 |
+
'media_type': mimeType,
|
| 24 |
+
'data': base64Data,
|
| 25 |
+
},
|
| 26 |
+
},
|
| 27 |
+
{ 'type': 'text', 'text': request.body.prompt },
|
| 28 |
+
],
|
| 29 |
+
},
|
| 30 |
+
],
|
| 31 |
+
max_tokens: 4096,
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
console.debug('Multimodal captioning request', body);
|
| 35 |
+
|
| 36 |
+
const result = await fetch(url, {
|
| 37 |
+
body: JSON.stringify(body),
|
| 38 |
+
method: 'POST',
|
| 39 |
+
headers: {
|
| 40 |
+
'Content-Type': 'application/json',
|
| 41 |
+
'anthropic-version': '2023-06-01',
|
| 42 |
+
'x-api-key': request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.CLAUDE),
|
| 43 |
+
},
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
if (!result.ok) {
|
| 47 |
+
const text = await result.text();
|
| 48 |
+
console.warn(`Claude API returned error: ${result.status} ${result.statusText}`, text);
|
| 49 |
+
return response.status(result.status).send({ error: true });
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/** @type {any} */
|
| 53 |
+
const generateResponseJson = await result.json();
|
| 54 |
+
const caption = generateResponseJson.content[0].text;
|
| 55 |
+
console.debug('Claude response:', generateResponseJson);
|
| 56 |
+
|
| 57 |
+
if (!caption) {
|
| 58 |
+
return response.status(500).send('No caption found');
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
return response.json({ caption });
|
| 62 |
+
} catch (error) {
|
| 63 |
+
console.error(error);
|
| 64 |
+
response.status(500).send('Internal server error');
|
| 65 |
+
}
|
| 66 |
+
});
|
src/endpoints/assets.js
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import path from 'node:path';
|
| 2 |
+
import fs from 'node:fs';
|
| 3 |
+
import { finished } from 'node:stream/promises';
|
| 4 |
+
|
| 5 |
+
import mime from 'mime-types';
|
| 6 |
+
import express from 'express';
|
| 7 |
+
import sanitize from 'sanitize-filename';
|
| 8 |
+
import fetch from 'node-fetch';
|
| 9 |
+
|
| 10 |
+
import { UNSAFE_EXTENSIONS } from '../constants.js';
|
| 11 |
+
import { clientRelativePath } from '../util.js';
|
| 12 |
+
|
| 13 |
+
const VALID_CATEGORIES = ['bgm', 'ambient', 'blip', 'live2d', 'vrm', 'character', 'temp'];
|
| 14 |
+
|
| 15 |
+
/**
|
| 16 |
+
* Validates the input filename for the asset.
|
| 17 |
+
* @param {string} inputFilename Input filename
|
| 18 |
+
* @returns {{error: boolean, message?: string}} Whether validation failed, and why if so
|
| 19 |
+
*/
|
| 20 |
+
export function validateAssetFileName(inputFilename) {
|
| 21 |
+
if (!/^[a-zA-Z0-9_\-.]+$/.test(inputFilename)) {
|
| 22 |
+
return {
|
| 23 |
+
error: true,
|
| 24 |
+
message: 'Illegal character in filename; only alphanumeric, \'_\', \'-\' are accepted.',
|
| 25 |
+
};
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const inputExtension = path.extname(inputFilename).toLowerCase();
|
| 29 |
+
if (UNSAFE_EXTENSIONS.some(ext => ext === inputExtension)) {
|
| 30 |
+
return {
|
| 31 |
+
error: true,
|
| 32 |
+
message: 'Forbidden file extension.',
|
| 33 |
+
};
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
if (inputFilename.startsWith('.')) {
|
| 37 |
+
return {
|
| 38 |
+
error: true,
|
| 39 |
+
message: 'Filename cannot start with \'.\'',
|
| 40 |
+
};
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
if (sanitize(inputFilename) !== inputFilename) {
|
| 44 |
+
return {
|
| 45 |
+
error: true,
|
| 46 |
+
message: 'Reserved or long filename.',
|
| 47 |
+
};
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
return { error: false };
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
/**
|
| 54 |
+
* Recursive function to get files
|
| 55 |
+
* @param {string} dir - The directory to search for files
|
| 56 |
+
* @param {string[]} files - The array of files to return
|
| 57 |
+
* @returns {string[]} - The array of files
|
| 58 |
+
*/
|
| 59 |
+
function getFiles(dir, files = []) {
|
| 60 |
+
if (!fs.existsSync(dir)) return files;
|
| 61 |
+
|
| 62 |
+
// Get an array of all files and directories in the passed directory using fs.readdirSync
|
| 63 |
+
const fileList = fs.readdirSync(dir, { withFileTypes: true });
|
| 64 |
+
// Create the full path of the file/directory by concatenating the passed directory and file/directory name
|
| 65 |
+
for (const file of fileList) {
|
| 66 |
+
const name = path.join(dir, file.name);
|
| 67 |
+
// Check if the current file/directory is a directory using fs.statSync
|
| 68 |
+
if (file.isDirectory()) {
|
| 69 |
+
// If it is a directory, recursively call the getFiles function with the directory path and the files array
|
| 70 |
+
getFiles(name, files);
|
| 71 |
+
} else {
|
| 72 |
+
// If it is a file, push the full path to the files array
|
| 73 |
+
files.push(name);
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
return files;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
/**
|
| 80 |
+
* Ensure that the asset folders exist.
|
| 81 |
+
* @param {import('../users.js').UserDirectoryList} directories - The user's directories
|
| 82 |
+
*/
|
| 83 |
+
function ensureFoldersExist(directories) {
|
| 84 |
+
const folderPath = path.join(directories.assets);
|
| 85 |
+
|
| 86 |
+
for (const category of VALID_CATEGORIES) {
|
| 87 |
+
const assetCategoryPath = path.join(folderPath, category);
|
| 88 |
+
if (fs.existsSync(assetCategoryPath) && !fs.statSync(assetCategoryPath).isDirectory()) {
|
| 89 |
+
fs.unlinkSync(assetCategoryPath);
|
| 90 |
+
}
|
| 91 |
+
if (!fs.existsSync(assetCategoryPath)) {
|
| 92 |
+
fs.mkdirSync(assetCategoryPath, { recursive: true });
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
export const router = express.Router();
|
| 98 |
+
|
| 99 |
+
/**
|
| 100 |
+
* HTTP POST handler function to retrieve name of all files of a given folder path.
|
| 101 |
+
*
|
| 102 |
+
* @param {Object} request - HTTP Request object. Require folder path in query
|
| 103 |
+
* @param {Object} response - HTTP Response object will contain a list of file path.
|
| 104 |
+
*
|
| 105 |
+
* @returns {void}
|
| 106 |
+
*/
|
| 107 |
+
router.post('/get', async (request, response) => {
|
| 108 |
+
const folderPath = path.join(request.user.directories.assets);
|
| 109 |
+
let output = {};
|
| 110 |
+
|
| 111 |
+
try {
|
| 112 |
+
if (fs.existsSync(folderPath) && fs.statSync(folderPath).isDirectory()) {
|
| 113 |
+
|
| 114 |
+
ensureFoldersExist(request.user.directories);
|
| 115 |
+
|
| 116 |
+
const folders = fs.readdirSync(folderPath, { withFileTypes: true })
|
| 117 |
+
.filter(file => file.isDirectory());
|
| 118 |
+
|
| 119 |
+
for (const { name: folder } of folders) {
|
| 120 |
+
if (folder == 'temp')
|
| 121 |
+
continue;
|
| 122 |
+
|
| 123 |
+
// Live2d assets
|
| 124 |
+
if (folder == 'live2d') {
|
| 125 |
+
output[folder] = [];
|
| 126 |
+
const live2d_folder = path.normalize(path.join(folderPath, folder));
|
| 127 |
+
const files = getFiles(live2d_folder);
|
| 128 |
+
//console.debug("FILE FOUND:",files)
|
| 129 |
+
for (let file of files) {
|
| 130 |
+
if (file.includes('model') && file.endsWith('.json')) {
|
| 131 |
+
//console.debug("Asset live2d model found:",file)
|
| 132 |
+
output[folder].push(clientRelativePath(request.user.directories.root, file));
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
continue;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
// VRM assets
|
| 139 |
+
if (folder == 'vrm') {
|
| 140 |
+
output[folder] = { 'model': [], 'animation': [] };
|
| 141 |
+
// Extract models
|
| 142 |
+
const vrm_model_folder = path.normalize(path.join(folderPath, 'vrm', 'model'));
|
| 143 |
+
let files = getFiles(vrm_model_folder);
|
| 144 |
+
//console.debug("FILE FOUND:",files)
|
| 145 |
+
for (let file of files) {
|
| 146 |
+
if (!file.endsWith('.placeholder')) {
|
| 147 |
+
//console.debug("Asset VRM model found:",file)
|
| 148 |
+
output['vrm']['model'].push(clientRelativePath(request.user.directories.root, file));
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
// Extract models
|
| 153 |
+
const vrm_animation_folder = path.normalize(path.join(folderPath, 'vrm', 'animation'));
|
| 154 |
+
files = getFiles(vrm_animation_folder);
|
| 155 |
+
//console.debug("FILE FOUND:",files)
|
| 156 |
+
for (let file of files) {
|
| 157 |
+
if (!file.endsWith('.placeholder')) {
|
| 158 |
+
//console.debug("Asset VRM animation found:",file)
|
| 159 |
+
output['vrm']['animation'].push(clientRelativePath(request.user.directories.root, file));
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
continue;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
// Other assets (bgm/ambient/blip)
|
| 166 |
+
const files = fs.readdirSync(path.join(folderPath, folder))
|
| 167 |
+
.filter(filename => {
|
| 168 |
+
return filename != '.placeholder';
|
| 169 |
+
});
|
| 170 |
+
output[folder] = [];
|
| 171 |
+
for (const file of files) {
|
| 172 |
+
output[folder].push(`assets/${folder}/${file}`);
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
catch (err) {
|
| 178 |
+
console.error(err);
|
| 179 |
+
}
|
| 180 |
+
return response.send(output);
|
| 181 |
+
});
|
| 182 |
+
|
| 183 |
+
/**
|
| 184 |
+
* HTTP POST handler function to download the requested asset.
|
| 185 |
+
*
|
| 186 |
+
* @param {Object} request - HTTP Request object, expects a url, a category and a filename.
|
| 187 |
+
* @param {Object} response - HTTP Response only gives status.
|
| 188 |
+
*
|
| 189 |
+
* @returns {void}
|
| 190 |
+
*/
|
| 191 |
+
router.post('/download', async (request, response) => {
|
| 192 |
+
const url = request.body.url;
|
| 193 |
+
const inputCategory = request.body.category;
|
| 194 |
+
|
| 195 |
+
// Check category
|
| 196 |
+
let category = null;
|
| 197 |
+
for (let i of VALID_CATEGORIES)
|
| 198 |
+
if (i == inputCategory)
|
| 199 |
+
category = i;
|
| 200 |
+
|
| 201 |
+
if (category === null) {
|
| 202 |
+
console.error('Bad request: unsupported asset category.');
|
| 203 |
+
return response.sendStatus(400);
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
// Validate filename
|
| 207 |
+
ensureFoldersExist(request.user.directories);
|
| 208 |
+
const validation = validateAssetFileName(request.body.filename);
|
| 209 |
+
if (validation.error)
|
| 210 |
+
return response.status(400).send(validation.message);
|
| 211 |
+
|
| 212 |
+
const temp_path = path.join(request.user.directories.assets, 'temp', request.body.filename);
|
| 213 |
+
const file_path = path.join(request.user.directories.assets, category, request.body.filename);
|
| 214 |
+
console.info('Request received to download', url, 'to', file_path);
|
| 215 |
+
|
| 216 |
+
try {
|
| 217 |
+
// Download to temp
|
| 218 |
+
const res = await fetch(url);
|
| 219 |
+
if (!res.ok || res.body === null) {
|
| 220 |
+
throw new Error(`Unexpected response ${res.statusText}`);
|
| 221 |
+
}
|
| 222 |
+
const destination = path.resolve(temp_path);
|
| 223 |
+
// Delete if previous download failed
|
| 224 |
+
if (fs.existsSync(temp_path)) {
|
| 225 |
+
fs.unlink(temp_path, (err) => {
|
| 226 |
+
if (err) throw err;
|
| 227 |
+
});
|
| 228 |
+
}
|
| 229 |
+
const fileStream = fs.createWriteStream(destination, { flags: 'wx' });
|
| 230 |
+
// @ts-ignore
|
| 231 |
+
await finished(res.body.pipe(fileStream));
|
| 232 |
+
|
| 233 |
+
if (category === 'character') {
|
| 234 |
+
const fileContent = fs.readFileSync(temp_path);
|
| 235 |
+
const contentType = mime.lookup(temp_path) || 'application/octet-stream';
|
| 236 |
+
response.setHeader('Content-Type', contentType);
|
| 237 |
+
response.send(fileContent);
|
| 238 |
+
fs.unlinkSync(temp_path);
|
| 239 |
+
return;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
// Move into asset place
|
| 243 |
+
console.info('Download finished, moving file from', temp_path, 'to', file_path);
|
| 244 |
+
fs.copyFileSync(temp_path, file_path);
|
| 245 |
+
fs.unlinkSync(temp_path);
|
| 246 |
+
response.sendStatus(200);
|
| 247 |
+
}
|
| 248 |
+
catch (error) {
|
| 249 |
+
console.error(error);
|
| 250 |
+
response.sendStatus(500);
|
| 251 |
+
}
|
| 252 |
+
});
|
| 253 |
+
|
| 254 |
+
/**
|
| 255 |
+
* HTTP POST handler function to delete the requested asset.
|
| 256 |
+
*
|
| 257 |
+
* @param {Object} request - HTTP Request object, expects a category and a filename
|
| 258 |
+
* @param {Object} response - HTTP Response only gives stats.
|
| 259 |
+
*
|
| 260 |
+
* @returns {void}
|
| 261 |
+
*/
|
| 262 |
+
router.post('/delete', async (request, response) => {
|
| 263 |
+
const inputCategory = request.body.category;
|
| 264 |
+
|
| 265 |
+
// Check category
|
| 266 |
+
let category = null;
|
| 267 |
+
for (let i of VALID_CATEGORIES)
|
| 268 |
+
if (i == inputCategory)
|
| 269 |
+
category = i;
|
| 270 |
+
|
| 271 |
+
if (category === null) {
|
| 272 |
+
console.error('Bad request: unsupported asset category.');
|
| 273 |
+
return response.sendStatus(400);
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
// Validate filename
|
| 277 |
+
const validation = validateAssetFileName(request.body.filename);
|
| 278 |
+
if (validation.error)
|
| 279 |
+
return response.status(400).send(validation.message);
|
| 280 |
+
|
| 281 |
+
const file_path = path.join(request.user.directories.assets, category, request.body.filename);
|
| 282 |
+
console.info('Request received to delete', category, file_path);
|
| 283 |
+
|
| 284 |
+
try {
|
| 285 |
+
// Delete if previous download failed
|
| 286 |
+
if (fs.existsSync(file_path)) {
|
| 287 |
+
fs.unlink(file_path, (err) => {
|
| 288 |
+
if (err) throw err;
|
| 289 |
+
});
|
| 290 |
+
console.info('Asset deleted.');
|
| 291 |
+
}
|
| 292 |
+
else {
|
| 293 |
+
console.error('Asset not found.');
|
| 294 |
+
response.sendStatus(400);
|
| 295 |
+
}
|
| 296 |
+
// Move into asset place
|
| 297 |
+
response.sendStatus(200);
|
| 298 |
+
}
|
| 299 |
+
catch (error) {
|
| 300 |
+
console.error(error);
|
| 301 |
+
response.sendStatus(500);
|
| 302 |
+
}
|
| 303 |
+
});
|
| 304 |
+
|
| 305 |
+
///////////////////////////////
|
| 306 |
+
/**
|
| 307 |
+
* HTTP POST handler function to retrieve a character background music list.
|
| 308 |
+
*
|
| 309 |
+
* @param {Object} request - HTTP Request object, expects a character name in the query.
|
| 310 |
+
* @param {Object} response - HTTP Response object will contain a list of audio file path.
|
| 311 |
+
*
|
| 312 |
+
* @returns {void}
|
| 313 |
+
*/
|
| 314 |
+
router.post('/character', async (request, response) => {
|
| 315 |
+
if (request.query.name === undefined) return response.sendStatus(400);
|
| 316 |
+
|
| 317 |
+
// For backwards compatibility, don't reject invalid character names, just sanitize them
|
| 318 |
+
const name = sanitize(request.query.name.toString());
|
| 319 |
+
const inputCategory = request.query.category;
|
| 320 |
+
|
| 321 |
+
// Check category
|
| 322 |
+
let category = null;
|
| 323 |
+
for (let i of VALID_CATEGORIES)
|
| 324 |
+
if (i == inputCategory)
|
| 325 |
+
category = i;
|
| 326 |
+
|
| 327 |
+
if (category === null) {
|
| 328 |
+
console.error('Bad request: unsupported asset category.');
|
| 329 |
+
return response.sendStatus(400);
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
const folderPath = path.join(request.user.directories.characters, name, category);
|
| 333 |
+
|
| 334 |
+
let output = [];
|
| 335 |
+
try {
|
| 336 |
+
if (fs.existsSync(folderPath) && fs.statSync(folderPath).isDirectory()) {
|
| 337 |
+
|
| 338 |
+
// Live2d assets
|
| 339 |
+
if (category == 'live2d') {
|
| 340 |
+
const folders = fs.readdirSync(folderPath, { withFileTypes: true });
|
| 341 |
+
for (const folderInfo of folders) {
|
| 342 |
+
if (!folderInfo.isDirectory()) continue;
|
| 343 |
+
|
| 344 |
+
const modelFolder = folderInfo.name;
|
| 345 |
+
const live2dModelPath = path.join(folderPath, modelFolder);
|
| 346 |
+
for (let file of fs.readdirSync(live2dModelPath)) {
|
| 347 |
+
//console.debug("Character live2d model found:", file)
|
| 348 |
+
if (file.includes('model') && file.endsWith('.json'))
|
| 349 |
+
output.push(path.join('characters', name, category, modelFolder, file));
|
| 350 |
+
}
|
| 351 |
+
}
|
| 352 |
+
return response.send(output);
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
// Other assets
|
| 356 |
+
const files = fs.readdirSync(folderPath)
|
| 357 |
+
.filter(filename => {
|
| 358 |
+
return filename != '.placeholder';
|
| 359 |
+
});
|
| 360 |
+
|
| 361 |
+
for (let i of files)
|
| 362 |
+
output.push(`/characters/${name}/${category}/${i}`);
|
| 363 |
+
}
|
| 364 |
+
return response.send(output);
|
| 365 |
+
}
|
| 366 |
+
catch (err) {
|
| 367 |
+
console.error(err);
|
| 368 |
+
return response.sendStatus(500);
|
| 369 |
+
}
|
| 370 |
+
});
|
src/endpoints/avatars.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import path from 'node:path';
|
| 2 |
+
import fs from 'node:fs';
|
| 3 |
+
|
| 4 |
+
import express from 'express';
|
| 5 |
+
import sanitize from 'sanitize-filename';
|
| 6 |
+
import { Jimp } from '../jimp.js';
|
| 7 |
+
import { sync as writeFileAtomicSync } from 'write-file-atomic';
|
| 8 |
+
|
| 9 |
+
import { getImages, tryParse } from '../util.js';
|
| 10 |
+
import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
|
| 11 |
+
import { applyAvatarCropResize } from './characters.js';
|
| 12 |
+
import { invalidateThumbnail } from './thumbnails.js';
|
| 13 |
+
import cacheBuster from '../middleware/cacheBuster.js';
|
| 14 |
+
|
| 15 |
+
export const router = express.Router();
|
| 16 |
+
|
| 17 |
+
router.post('/get', function (request, response) {
|
| 18 |
+
const images = getImages(request.user.directories.avatars);
|
| 19 |
+
response.send(images);
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
router.post('/delete', getFileNameValidationFunction('avatar'), function (request, response) {
|
| 23 |
+
if (!request.body) return response.sendStatus(400);
|
| 24 |
+
|
| 25 |
+
if (request.body.avatar !== sanitize(request.body.avatar)) {
|
| 26 |
+
console.error('Malicious avatar name prevented');
|
| 27 |
+
return response.sendStatus(403);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const fileName = path.join(request.user.directories.avatars, sanitize(request.body.avatar));
|
| 31 |
+
|
| 32 |
+
if (fs.existsSync(fileName)) {
|
| 33 |
+
fs.unlinkSync(fileName);
|
| 34 |
+
invalidateThumbnail(request.user.directories, 'persona', sanitize(request.body.avatar));
|
| 35 |
+
return response.send({ result: 'ok' });
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
return response.sendStatus(404);
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
router.post('/upload', getFileNameValidationFunction('overwrite_name'), async (request, response) => {
|
| 42 |
+
if (!request.file) return response.sendStatus(400);
|
| 43 |
+
|
| 44 |
+
try {
|
| 45 |
+
const pathToUpload = path.join(request.file.destination, request.file.filename);
|
| 46 |
+
const crop = tryParse(request.query.crop);
|
| 47 |
+
const rawImg = await Jimp.read(pathToUpload);
|
| 48 |
+
const image = await applyAvatarCropResize(rawImg, crop);
|
| 49 |
+
|
| 50 |
+
// Remove previous thumbnail and bust cache if overwriting
|
| 51 |
+
if (request.body.overwrite_name) {
|
| 52 |
+
invalidateThumbnail(request.user.directories, 'persona', sanitize(request.body.overwrite_name));
|
| 53 |
+
cacheBuster.bust(request, response);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
const filename = sanitize(request.body.overwrite_name || `${Date.now()}.png`);
|
| 57 |
+
const pathToNewFile = path.join(request.user.directories.avatars, filename);
|
| 58 |
+
writeFileAtomicSync(pathToNewFile, image);
|
| 59 |
+
fs.unlinkSync(pathToUpload);
|
| 60 |
+
return response.send({ path: filename });
|
| 61 |
+
} catch (err) {
|
| 62 |
+
console.error('Error uploading user avatar:', err);
|
| 63 |
+
return response.status(400).send('Is not a valid image');
|
| 64 |
+
}
|
| 65 |
+
});
|
src/endpoints/azure.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fetch from 'node-fetch';
|
| 2 |
+
import { Router } from 'express';
|
| 3 |
+
|
| 4 |
+
import { readSecret, SECRET_KEYS } from './secrets.js';
|
| 5 |
+
|
| 6 |
+
export const router = Router();
|
| 7 |
+
|
| 8 |
+
router.post('/list', async (req, res) => {
|
| 9 |
+
try {
|
| 10 |
+
const key = readSecret(req.user.directories, SECRET_KEYS.AZURE_TTS);
|
| 11 |
+
|
| 12 |
+
if (!key) {
|
| 13 |
+
console.warn('Azure TTS API Key not set');
|
| 14 |
+
return res.sendStatus(403);
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const region = req.body.region;
|
| 18 |
+
|
| 19 |
+
if (!region) {
|
| 20 |
+
console.warn('Azure TTS region not set');
|
| 21 |
+
return res.sendStatus(400);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const url = `https://${region}.tts.speech.microsoft.com/cognitiveservices/voices/list`;
|
| 25 |
+
|
| 26 |
+
const response = await fetch(url, {
|
| 27 |
+
method: 'GET',
|
| 28 |
+
headers: {
|
| 29 |
+
'Ocp-Apim-Subscription-Key': key,
|
| 30 |
+
},
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
if (!response.ok) {
|
| 34 |
+
console.warn('Azure Request failed', response.status, response.statusText);
|
| 35 |
+
return res.sendStatus(500);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
const voices = await response.json();
|
| 39 |
+
return res.json(voices);
|
| 40 |
+
} catch (error) {
|
| 41 |
+
console.error('Azure Request failed', error);
|
| 42 |
+
return res.sendStatus(500);
|
| 43 |
+
}
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
router.post('/generate', async (req, res) => {
|
| 47 |
+
try {
|
| 48 |
+
const key = readSecret(req.user.directories, SECRET_KEYS.AZURE_TTS);
|
| 49 |
+
|
| 50 |
+
if (!key) {
|
| 51 |
+
console.warn('Azure TTS API Key not set');
|
| 52 |
+
return res.sendStatus(403);
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
const { text, voice, region } = req.body;
|
| 56 |
+
if (!text || !voice || !region) {
|
| 57 |
+
console.warn('Missing required parameters');
|
| 58 |
+
return res.sendStatus(400);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
const url = `https://${region}.tts.speech.microsoft.com/cognitiveservices/v1`;
|
| 62 |
+
const lang = String(voice).split('-').slice(0, 2).join('-');
|
| 63 |
+
const escapedText = String(text).replace(/&/g, '&').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': 'webm-24khz-16bit-mono-opus',
|
| 72 |
+
},
|
| 73 |
+
body: ssml,
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
if (!response.ok) {
|
| 77 |
+
console.warn('Azure Request failed', response.status, response.statusText);
|
| 78 |
+
return res.sendStatus(500);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
const audio = Buffer.from(await response.arrayBuffer());
|
| 82 |
+
res.set('Content-Type', 'audio/ogg');
|
| 83 |
+
return res.send(audio);
|
| 84 |
+
} catch (error) {
|
| 85 |
+
console.error('Azure Request failed', error);
|
| 86 |
+
return res.sendStatus(500);
|
| 87 |
+
}
|
| 88 |
+
});
|
src/endpoints/backends/chat-completions.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/endpoints/backends/kobold.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from 'node:fs';
|
| 2 |
+
import express from 'express';
|
| 3 |
+
import fetch from 'node-fetch';
|
| 4 |
+
|
| 5 |
+
import { forwardFetchResponse, delay } from '../../util.js';
|
| 6 |
+
import { getOverrideHeaders, setAdditionalHeaders, setAdditionalHeadersByType } from '../../additional-headers.js';
|
| 7 |
+
import { TEXTGEN_TYPES } from '../../constants.js';
|
| 8 |
+
|
| 9 |
+
export const router = express.Router();
|
| 10 |
+
|
| 11 |
+
router.post('/generate', async function (request, response_generate) {
|
| 12 |
+
if (!request.body) return response_generate.sendStatus(400);
|
| 13 |
+
|
| 14 |
+
if (request.body.api_server.indexOf('localhost') != -1) {
|
| 15 |
+
request.body.api_server = request.body.api_server.replace('localhost', '127.0.0.1');
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const request_prompt = request.body.prompt;
|
| 19 |
+
const controller = new AbortController();
|
| 20 |
+
request.socket.removeAllListeners('close');
|
| 21 |
+
request.socket.on('close', async function () {
|
| 22 |
+
if (request.body.can_abort && !response_generate.writableEnded) {
|
| 23 |
+
try {
|
| 24 |
+
console.info('Aborting Kobold generation...');
|
| 25 |
+
// send abort signal to koboldcpp
|
| 26 |
+
const abortResponse = await fetch(`${request.body.api_server}/extra/abort`, {
|
| 27 |
+
method: 'POST',
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
if (!abortResponse.ok) {
|
| 31 |
+
console.error('Error sending abort request to Kobold:', abortResponse.status);
|
| 32 |
+
}
|
| 33 |
+
} catch (error) {
|
| 34 |
+
console.error(error);
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
controller.abort();
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
let this_settings = {
|
| 41 |
+
prompt: request_prompt,
|
| 42 |
+
use_story: false,
|
| 43 |
+
use_memory: false,
|
| 44 |
+
use_authors_note: false,
|
| 45 |
+
use_world_info: false,
|
| 46 |
+
max_context_length: request.body.max_context_length,
|
| 47 |
+
max_length: request.body.max_length,
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
if (!request.body.gui_settings) {
|
| 51 |
+
this_settings = {
|
| 52 |
+
prompt: request_prompt,
|
| 53 |
+
use_story: false,
|
| 54 |
+
use_memory: false,
|
| 55 |
+
use_authors_note: false,
|
| 56 |
+
use_world_info: false,
|
| 57 |
+
max_context_length: request.body.max_context_length,
|
| 58 |
+
max_length: request.body.max_length,
|
| 59 |
+
rep_pen: request.body.rep_pen,
|
| 60 |
+
rep_pen_range: request.body.rep_pen_range,
|
| 61 |
+
rep_pen_slope: request.body.rep_pen_slope,
|
| 62 |
+
temperature: request.body.temperature,
|
| 63 |
+
tfs: request.body.tfs,
|
| 64 |
+
top_a: request.body.top_a,
|
| 65 |
+
top_k: request.body.top_k,
|
| 66 |
+
top_p: request.body.top_p,
|
| 67 |
+
min_p: request.body.min_p,
|
| 68 |
+
typical: request.body.typical,
|
| 69 |
+
sampler_order: request.body.sampler_order,
|
| 70 |
+
singleline: !!request.body.singleline,
|
| 71 |
+
use_default_badwordsids: request.body.use_default_badwordsids,
|
| 72 |
+
mirostat: request.body.mirostat,
|
| 73 |
+
mirostat_eta: request.body.mirostat_eta,
|
| 74 |
+
mirostat_tau: request.body.mirostat_tau,
|
| 75 |
+
grammar: request.body.grammar,
|
| 76 |
+
sampler_seed: request.body.sampler_seed,
|
| 77 |
+
};
|
| 78 |
+
if (request.body.stop_sequence) {
|
| 79 |
+
this_settings['stop_sequence'] = request.body.stop_sequence;
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
console.debug(this_settings);
|
| 84 |
+
const args = {
|
| 85 |
+
body: JSON.stringify(this_settings),
|
| 86 |
+
headers: Object.assign(
|
| 87 |
+
{ 'Content-Type': 'application/json' },
|
| 88 |
+
getOverrideHeaders((new URL(request.body.api_server))?.host),
|
| 89 |
+
),
|
| 90 |
+
signal: controller.signal,
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
const MAX_RETRIES = 50;
|
| 94 |
+
const delayAmount = 2500;
|
| 95 |
+
for (let i = 0; i < MAX_RETRIES; i++) {
|
| 96 |
+
try {
|
| 97 |
+
const url = request.body.streaming ? `${request.body.api_server}/extra/generate/stream` : `${request.body.api_server}/v1/generate`;
|
| 98 |
+
const response = await fetch(url, { method: 'POST', ...args });
|
| 99 |
+
|
| 100 |
+
if (request.body.streaming) {
|
| 101 |
+
// Pipe remote SSE stream to Express response
|
| 102 |
+
forwardFetchResponse(response, response_generate);
|
| 103 |
+
return;
|
| 104 |
+
} else {
|
| 105 |
+
if (!response.ok) {
|
| 106 |
+
const errorText = await response.text();
|
| 107 |
+
console.warn(`Kobold returned error: ${response.status} ${response.statusText} ${errorText}`);
|
| 108 |
+
|
| 109 |
+
try {
|
| 110 |
+
const errorJson = JSON.parse(errorText);
|
| 111 |
+
const message = errorJson?.detail?.msg || errorText;
|
| 112 |
+
return response_generate.status(400).send({ error: { message } });
|
| 113 |
+
} catch {
|
| 114 |
+
return response_generate.status(400).send({ error: { message: errorText } });
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
const data = await response.json();
|
| 119 |
+
console.debug('Endpoint response:', data);
|
| 120 |
+
return response_generate.send(data);
|
| 121 |
+
}
|
| 122 |
+
} catch (error) {
|
| 123 |
+
// response
|
| 124 |
+
switch (error?.status) {
|
| 125 |
+
case 403:
|
| 126 |
+
case 503: // retry in case of temporary service issue, possibly caused by a queue failure?
|
| 127 |
+
console.warn(`KoboldAI is busy. Retry attempt ${i + 1} of ${MAX_RETRIES}...`);
|
| 128 |
+
await delay(delayAmount);
|
| 129 |
+
break;
|
| 130 |
+
default:
|
| 131 |
+
if ('status' in error) {
|
| 132 |
+
console.error('Status Code from Kobold:', error.status);
|
| 133 |
+
}
|
| 134 |
+
return response_generate.send({ error: true });
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
console.error('Max retries exceeded. Giving up.');
|
| 140 |
+
return response_generate.send({ error: true });
|
| 141 |
+
});
|
| 142 |
+
|
| 143 |
+
router.post('/status', async function (request, response) {
|
| 144 |
+
if (!request.body) return response.sendStatus(400);
|
| 145 |
+
let api_server = request.body.api_server;
|
| 146 |
+
if (api_server.indexOf('localhost') != -1) {
|
| 147 |
+
api_server = api_server.replace('localhost', '127.0.0.1');
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
const args = {
|
| 151 |
+
headers: { 'Content-Type': 'application/json' },
|
| 152 |
+
};
|
| 153 |
+
|
| 154 |
+
setAdditionalHeaders(request, args, api_server);
|
| 155 |
+
|
| 156 |
+
const result = {};
|
| 157 |
+
|
| 158 |
+
/** @type {any} */
|
| 159 |
+
const [koboldUnitedResponse, koboldExtraResponse, koboldModelResponse] = await Promise.all([
|
| 160 |
+
// We catch errors both from the response not having a successful HTTP status and from JSON parsing failing
|
| 161 |
+
|
| 162 |
+
// Kobold United API version
|
| 163 |
+
fetch(`${api_server}/v1/info/version`).then(response => {
|
| 164 |
+
if (!response.ok) throw new Error(`Kobold API error: ${response.status, response.statusText}`);
|
| 165 |
+
return response.json();
|
| 166 |
+
}).catch(() => ({ result: '0.0.0' })),
|
| 167 |
+
|
| 168 |
+
// KoboldCpp version
|
| 169 |
+
fetch(`${api_server}/extra/version`).then(response => {
|
| 170 |
+
if (!response.ok) throw new Error(`Kobold API error: ${response.status, response.statusText}`);
|
| 171 |
+
return response.json();
|
| 172 |
+
}).catch(() => ({ version: '0.0' })),
|
| 173 |
+
|
| 174 |
+
// Current model
|
| 175 |
+
fetch(`${api_server}/v1/model`).then(response => {
|
| 176 |
+
if (!response.ok) throw new Error(`Kobold API error: ${response.status, response.statusText}`);
|
| 177 |
+
return response.json();
|
| 178 |
+
}).catch(() => null),
|
| 179 |
+
]);
|
| 180 |
+
|
| 181 |
+
result.koboldUnitedVersion = koboldUnitedResponse.result;
|
| 182 |
+
result.koboldCppVersion = koboldExtraResponse.result;
|
| 183 |
+
result.model = !koboldModelResponse || koboldModelResponse.result === 'ReadOnly' ?
|
| 184 |
+
'no_connection' :
|
| 185 |
+
koboldModelResponse.result;
|
| 186 |
+
|
| 187 |
+
response.send(result);
|
| 188 |
+
});
|
| 189 |
+
|
| 190 |
+
router.post('/transcribe-audio', async function (request, response) {
|
| 191 |
+
try {
|
| 192 |
+
const server = request.body.server;
|
| 193 |
+
|
| 194 |
+
if (!server) {
|
| 195 |
+
console.error('Server is not set');
|
| 196 |
+
return response.sendStatus(400);
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
if (!request.file) {
|
| 200 |
+
console.error('No audio file found');
|
| 201 |
+
return response.sendStatus(400);
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
console.debug('Transcribing audio with KoboldCpp', server);
|
| 205 |
+
|
| 206 |
+
const fileBase64 = fs.readFileSync(request.file.path).toString('base64');
|
| 207 |
+
fs.unlinkSync(request.file.path);
|
| 208 |
+
|
| 209 |
+
const headers = {};
|
| 210 |
+
setAdditionalHeadersByType(headers, TEXTGEN_TYPES.KOBOLDCPP, server, request.user.directories);
|
| 211 |
+
|
| 212 |
+
const url = new URL(server);
|
| 213 |
+
url.pathname = '/api/extra/transcribe';
|
| 214 |
+
|
| 215 |
+
const result = await fetch(url, {
|
| 216 |
+
method: 'POST',
|
| 217 |
+
headers: {
|
| 218 |
+
...headers,
|
| 219 |
+
},
|
| 220 |
+
body: JSON.stringify({
|
| 221 |
+
prompt: '',
|
| 222 |
+
audio_data: fileBase64,
|
| 223 |
+
}),
|
| 224 |
+
});
|
| 225 |
+
|
| 226 |
+
if (!result.ok) {
|
| 227 |
+
const text = await result.text();
|
| 228 |
+
console.error('KoboldCpp request failed', result.statusText, text);
|
| 229 |
+
return response.status(500).send(text);
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
const data = await result.json();
|
| 233 |
+
console.debug('KoboldCpp transcription response', data);
|
| 234 |
+
return response.json(data);
|
| 235 |
+
} catch (error) {
|
| 236 |
+
console.error('KoboldCpp transcription failed', error);
|
| 237 |
+
response.status(500).send('Internal server error');
|
| 238 |
+
}
|
| 239 |
+
});
|
| 240 |
+
|
| 241 |
+
router.post('/embed', async function (request, response) {
|
| 242 |
+
try {
|
| 243 |
+
const { server, items } = request.body;
|
| 244 |
+
|
| 245 |
+
if (!server) {
|
| 246 |
+
console.warn('KoboldCpp URL is not set');
|
| 247 |
+
return response.sendStatus(400);
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
const headers = {};
|
| 251 |
+
setAdditionalHeadersByType(headers, TEXTGEN_TYPES.KOBOLDCPP, server, request.user.directories);
|
| 252 |
+
|
| 253 |
+
const embeddingsUrl = new URL(server);
|
| 254 |
+
embeddingsUrl.pathname = '/api/extra/embeddings';
|
| 255 |
+
|
| 256 |
+
const embeddingsResult = await fetch(embeddingsUrl, {
|
| 257 |
+
method: 'POST',
|
| 258 |
+
headers: {
|
| 259 |
+
...headers,
|
| 260 |
+
},
|
| 261 |
+
body: JSON.stringify({
|
| 262 |
+
input: items,
|
| 263 |
+
}),
|
| 264 |
+
});
|
| 265 |
+
|
| 266 |
+
/** @type {any} */
|
| 267 |
+
const data = await embeddingsResult.json();
|
| 268 |
+
|
| 269 |
+
if (!Array.isArray(data?.data)) {
|
| 270 |
+
console.warn('KoboldCpp API response was not an array');
|
| 271 |
+
return response.sendStatus(500);
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
const model = data.model || 'unknown';
|
| 275 |
+
const embeddings = data.data.map(x => Array.isArray(x) ? x[0] : x).sort((a, b) => a.index - b.index).map(x => x.embedding);
|
| 276 |
+
return response.json({ model, embeddings });
|
| 277 |
+
} catch (error) {
|
| 278 |
+
console.error('KoboldCpp embedding failed', error);
|
| 279 |
+
response.status(500).send('Internal server error');
|
| 280 |
+
}
|
| 281 |
+
});
|
src/endpoints/backends/text-completions.js
ADDED
|
@@ -0,0 +1,643 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Readable } from 'node:stream';
|
| 2 |
+
import fetch from 'node-fetch';
|
| 3 |
+
import express from 'express';
|
| 4 |
+
import _ from 'lodash';
|
| 5 |
+
|
| 6 |
+
import {
|
| 7 |
+
TEXTGEN_TYPES,
|
| 8 |
+
TOGETHERAI_KEYS,
|
| 9 |
+
OLLAMA_KEYS,
|
| 10 |
+
INFERMATICAI_KEYS,
|
| 11 |
+
OPENROUTER_KEYS,
|
| 12 |
+
VLLM_KEYS,
|
| 13 |
+
FEATHERLESS_KEYS,
|
| 14 |
+
OPENAI_KEYS,
|
| 15 |
+
} from '../../constants.js';
|
| 16 |
+
import { forwardFetchResponse, trimV1, getConfigValue } from '../../util.js';
|
| 17 |
+
import { setAdditionalHeaders } from '../../additional-headers.js';
|
| 18 |
+
import { createHash } from 'node:crypto';
|
| 19 |
+
|
| 20 |
+
export const router = express.Router();
|
| 21 |
+
|
| 22 |
+
/**
|
| 23 |
+
* Special boy's steaming routine. Wrap this abomination into proper SSE stream.
|
| 24 |
+
* @param {import('node-fetch').Response} jsonStream JSON stream
|
| 25 |
+
* @param {import('express').Request} request Express request
|
| 26 |
+
* @param {import('express').Response} response Express response
|
| 27 |
+
* @returns {Promise<any>} Nothing valuable
|
| 28 |
+
*/
|
| 29 |
+
async function parseOllamaStream(jsonStream, request, response) {
|
| 30 |
+
try {
|
| 31 |
+
if (!jsonStream.body) {
|
| 32 |
+
throw new Error('No body in the response');
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
let partialData = '';
|
| 36 |
+
jsonStream.body.on('data', (data) => {
|
| 37 |
+
const chunk = data.toString();
|
| 38 |
+
partialData += chunk;
|
| 39 |
+
while (true) {
|
| 40 |
+
let json;
|
| 41 |
+
try {
|
| 42 |
+
json = JSON.parse(partialData);
|
| 43 |
+
} catch (e) {
|
| 44 |
+
break;
|
| 45 |
+
}
|
| 46 |
+
const text = json.response || '';
|
| 47 |
+
const thinking = json.thinking || '';
|
| 48 |
+
const chunk = { choices: [{ text, thinking }] };
|
| 49 |
+
response.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
| 50 |
+
partialData = '';
|
| 51 |
+
}
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
request.socket.on('close', function () {
|
| 55 |
+
if (jsonStream.body instanceof Readable) jsonStream.body.destroy();
|
| 56 |
+
response.end();
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
jsonStream.body.on('end', () => {
|
| 60 |
+
console.info('Streaming request finished');
|
| 61 |
+
response.write('data: [DONE]\n\n');
|
| 62 |
+
response.end();
|
| 63 |
+
});
|
| 64 |
+
} catch (error) {
|
| 65 |
+
console.error('Error forwarding streaming response:', error);
|
| 66 |
+
if (!response.headersSent) {
|
| 67 |
+
return response.status(500).send({ error: true });
|
| 68 |
+
} else {
|
| 69 |
+
return response.end();
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/**
|
| 75 |
+
* Abort KoboldCpp generation request.
|
| 76 |
+
* @param {import('express').Request} request the generation request
|
| 77 |
+
* @param {string} url Server base URL
|
| 78 |
+
* @returns {Promise<void>} Promise resolving when we are done
|
| 79 |
+
*/
|
| 80 |
+
async function abortKoboldCppRequest(request, url) {
|
| 81 |
+
try {
|
| 82 |
+
console.info('Aborting Kobold generation...');
|
| 83 |
+
const args = {
|
| 84 |
+
method: 'POST',
|
| 85 |
+
headers: {},
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
setAdditionalHeaders(request, args, url);
|
| 89 |
+
const abortResponse = await fetch(`${url}/api/extra/abort`, args);
|
| 90 |
+
|
| 91 |
+
if (!abortResponse.ok) {
|
| 92 |
+
console.error('Error sending abort request to Kobold:', abortResponse.status, abortResponse.statusText);
|
| 93 |
+
}
|
| 94 |
+
} catch (error) {
|
| 95 |
+
console.error(error);
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
//************** Ooba/OpenAI text completions API
|
| 100 |
+
router.post('/status', async function (request, response) {
|
| 101 |
+
if (!request.body) return response.sendStatus(400);
|
| 102 |
+
|
| 103 |
+
try {
|
| 104 |
+
if (request.body.api_server.indexOf('localhost') !== -1) {
|
| 105 |
+
request.body.api_server = request.body.api_server.replace('localhost', '127.0.0.1');
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
console.debug('Trying to connect to API', request.body);
|
| 109 |
+
const baseUrl = trimV1(request.body.api_server);
|
| 110 |
+
|
| 111 |
+
const args = {
|
| 112 |
+
headers: { 'Content-Type': 'application/json' },
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
setAdditionalHeaders(request, args, baseUrl);
|
| 116 |
+
|
| 117 |
+
const apiType = request.body.api_type;
|
| 118 |
+
let url = baseUrl;
|
| 119 |
+
let result = '';
|
| 120 |
+
|
| 121 |
+
switch (apiType) {
|
| 122 |
+
case TEXTGEN_TYPES.GENERIC:
|
| 123 |
+
case TEXTGEN_TYPES.OOBA:
|
| 124 |
+
case TEXTGEN_TYPES.VLLM:
|
| 125 |
+
case TEXTGEN_TYPES.APHRODITE:
|
| 126 |
+
case TEXTGEN_TYPES.KOBOLDCPP:
|
| 127 |
+
case TEXTGEN_TYPES.LLAMACPP:
|
| 128 |
+
case TEXTGEN_TYPES.INFERMATICAI:
|
| 129 |
+
case TEXTGEN_TYPES.OPENROUTER:
|
| 130 |
+
case TEXTGEN_TYPES.FEATHERLESS:
|
| 131 |
+
url += '/v1/models';
|
| 132 |
+
break;
|
| 133 |
+
case TEXTGEN_TYPES.DREAMGEN:
|
| 134 |
+
url += '/api/openai/v1/models';
|
| 135 |
+
break;
|
| 136 |
+
case TEXTGEN_TYPES.MANCER:
|
| 137 |
+
url += '/oai/v1/models';
|
| 138 |
+
break;
|
| 139 |
+
case TEXTGEN_TYPES.TABBY:
|
| 140 |
+
url += '/v1/model/list';
|
| 141 |
+
break;
|
| 142 |
+
case TEXTGEN_TYPES.TOGETHERAI:
|
| 143 |
+
url += '/api/models?&info';
|
| 144 |
+
break;
|
| 145 |
+
case TEXTGEN_TYPES.OLLAMA:
|
| 146 |
+
url += '/api/tags';
|
| 147 |
+
break;
|
| 148 |
+
case TEXTGEN_TYPES.HUGGINGFACE:
|
| 149 |
+
url += '/info';
|
| 150 |
+
break;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
const modelsReply = await fetch(url, args);
|
| 154 |
+
const isPossiblyLmStudio = modelsReply.headers.get('x-powered-by') === 'Express';
|
| 155 |
+
|
| 156 |
+
if (!modelsReply.ok) {
|
| 157 |
+
console.error('Models endpoint is offline.');
|
| 158 |
+
return response.sendStatus(400);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
/** @type {any} */
|
| 162 |
+
let data = await modelsReply.json();
|
| 163 |
+
|
| 164 |
+
// Rewrap to OAI-like response
|
| 165 |
+
if (apiType === TEXTGEN_TYPES.TOGETHERAI && Array.isArray(data)) {
|
| 166 |
+
data = { data: data.map(x => ({ id: x.name, ...x })) };
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
if (apiType === TEXTGEN_TYPES.OLLAMA && Array.isArray(data.models)) {
|
| 170 |
+
data = { data: data.models.map(x => ({ id: x.name, ...x })) };
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
if (apiType === TEXTGEN_TYPES.HUGGINGFACE) {
|
| 174 |
+
data = { data: [] };
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
if (!Array.isArray(data.data)) {
|
| 178 |
+
console.error('Models response is not an array.');
|
| 179 |
+
return response.sendStatus(400);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
const modelIds = data.data.map(x => x.id);
|
| 183 |
+
console.info('Models available:', modelIds);
|
| 184 |
+
|
| 185 |
+
// Set result to the first model ID
|
| 186 |
+
result = modelIds[0] || 'Valid';
|
| 187 |
+
|
| 188 |
+
if (apiType === TEXTGEN_TYPES.OOBA && !isPossiblyLmStudio) {
|
| 189 |
+
try {
|
| 190 |
+
const modelInfoUrl = baseUrl + '/v1/internal/model/info';
|
| 191 |
+
const modelInfoReply = await fetch(modelInfoUrl, args);
|
| 192 |
+
|
| 193 |
+
if (modelInfoReply.ok) {
|
| 194 |
+
/** @type {any} */
|
| 195 |
+
const modelInfo = await modelInfoReply.json();
|
| 196 |
+
console.debug('Ooba model info:', modelInfo);
|
| 197 |
+
|
| 198 |
+
const modelName = modelInfo?.model_name;
|
| 199 |
+
result = modelName || result;
|
| 200 |
+
response.setHeader('x-supports-tokenization', 'true');
|
| 201 |
+
}
|
| 202 |
+
} catch (error) {
|
| 203 |
+
console.error(`Failed to get Ooba model info: ${error}`);
|
| 204 |
+
}
|
| 205 |
+
} else if (apiType === TEXTGEN_TYPES.TABBY) {
|
| 206 |
+
try {
|
| 207 |
+
const modelInfoUrl = baseUrl + '/v1/model';
|
| 208 |
+
const modelInfoReply = await fetch(modelInfoUrl, args);
|
| 209 |
+
|
| 210 |
+
if (modelInfoReply.ok) {
|
| 211 |
+
/** @type {any} */
|
| 212 |
+
const modelInfo = await modelInfoReply.json();
|
| 213 |
+
console.debug('Tabby model info:', modelInfo);
|
| 214 |
+
|
| 215 |
+
const modelName = modelInfo?.id;
|
| 216 |
+
result = modelName || result;
|
| 217 |
+
} else {
|
| 218 |
+
// TabbyAPI returns an error 400 if a model isn't loaded
|
| 219 |
+
|
| 220 |
+
result = 'None';
|
| 221 |
+
}
|
| 222 |
+
} catch (error) {
|
| 223 |
+
console.error(`Failed to get TabbyAPI model info: ${error}`);
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
return response.send({ result, data: data.data });
|
| 228 |
+
} catch (error) {
|
| 229 |
+
console.error(error);
|
| 230 |
+
return response.sendStatus(500);
|
| 231 |
+
}
|
| 232 |
+
});
|
| 233 |
+
|
| 234 |
+
router.post('/props', async function (request, response) {
|
| 235 |
+
if (!request.body.api_server) return response.sendStatus(400);
|
| 236 |
+
|
| 237 |
+
try {
|
| 238 |
+
const baseUrl = trimV1(request.body.api_server);
|
| 239 |
+
const args = {
|
| 240 |
+
headers: {},
|
| 241 |
+
};
|
| 242 |
+
|
| 243 |
+
setAdditionalHeaders(request, args, baseUrl);
|
| 244 |
+
|
| 245 |
+
const apiType = request.body.api_type;
|
| 246 |
+
let propsUrl = baseUrl + '/props';
|
| 247 |
+
if (apiType === TEXTGEN_TYPES.LLAMACPP && request.body.model) {
|
| 248 |
+
propsUrl += `?model=${encodeURIComponent(request.body.model)}`;
|
| 249 |
+
console.debug(`Querying llama-server props with model parameter: ${request.body.model}`);
|
| 250 |
+
}
|
| 251 |
+
const propsReply = await fetch(propsUrl, args);
|
| 252 |
+
|
| 253 |
+
if (!propsReply.ok) {
|
| 254 |
+
return response.sendStatus(400);
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
/** @type {any} */
|
| 258 |
+
const props = await propsReply.json();
|
| 259 |
+
// TEMPORARY: llama.cpp's /props endpoint has a bug which replaces the last newline with a \0
|
| 260 |
+
if (apiType === TEXTGEN_TYPES.LLAMACPP && props['chat_template'] && props['chat_template'].endsWith('\u0000')) {
|
| 261 |
+
props['chat_template'] = props['chat_template'].slice(0, -1) + '\n';
|
| 262 |
+
}
|
| 263 |
+
props['chat_template_hash'] = createHash('sha256').update(props['chat_template']).digest('hex');
|
| 264 |
+
console.debug(`Model properties: ${JSON.stringify(props)}`);
|
| 265 |
+
return response.send(props);
|
| 266 |
+
} catch (error) {
|
| 267 |
+
console.error(error);
|
| 268 |
+
return response.sendStatus(500);
|
| 269 |
+
}
|
| 270 |
+
});
|
| 271 |
+
|
| 272 |
+
router.post('/generate', async function (request, response) {
|
| 273 |
+
if (!request.body) return response.sendStatus(400);
|
| 274 |
+
|
| 275 |
+
try {
|
| 276 |
+
if (request.body.api_server.indexOf('localhost') !== -1) {
|
| 277 |
+
request.body.api_server = request.body.api_server.replace('localhost', '127.0.0.1');
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
const apiType = request.body.api_type;
|
| 281 |
+
const baseUrl = request.body.api_server;
|
| 282 |
+
console.debug(request.body);
|
| 283 |
+
|
| 284 |
+
const controller = new AbortController();
|
| 285 |
+
request.socket.removeAllListeners('close');
|
| 286 |
+
request.socket.on('close', async function () {
|
| 287 |
+
if (request.body.api_type === TEXTGEN_TYPES.KOBOLDCPP && !response.writableEnded) {
|
| 288 |
+
await abortKoboldCppRequest(request, trimV1(baseUrl));
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
controller.abort();
|
| 292 |
+
});
|
| 293 |
+
|
| 294 |
+
let url = trimV1(baseUrl);
|
| 295 |
+
|
| 296 |
+
switch (request.body.api_type) {
|
| 297 |
+
case TEXTGEN_TYPES.GENERIC:
|
| 298 |
+
case TEXTGEN_TYPES.VLLM:
|
| 299 |
+
case TEXTGEN_TYPES.FEATHERLESS:
|
| 300 |
+
case TEXTGEN_TYPES.APHRODITE:
|
| 301 |
+
case TEXTGEN_TYPES.OOBA:
|
| 302 |
+
case TEXTGEN_TYPES.TABBY:
|
| 303 |
+
case TEXTGEN_TYPES.KOBOLDCPP:
|
| 304 |
+
case TEXTGEN_TYPES.TOGETHERAI:
|
| 305 |
+
case TEXTGEN_TYPES.INFERMATICAI:
|
| 306 |
+
case TEXTGEN_TYPES.HUGGINGFACE:
|
| 307 |
+
url += '/v1/completions';
|
| 308 |
+
break;
|
| 309 |
+
case TEXTGEN_TYPES.DREAMGEN:
|
| 310 |
+
url += '/api/openai/v1/completions';
|
| 311 |
+
break;
|
| 312 |
+
case TEXTGEN_TYPES.MANCER:
|
| 313 |
+
url += '/oai/v1/completions';
|
| 314 |
+
break;
|
| 315 |
+
case TEXTGEN_TYPES.LLAMACPP:
|
| 316 |
+
url += '/completion';
|
| 317 |
+
break;
|
| 318 |
+
case TEXTGEN_TYPES.OLLAMA:
|
| 319 |
+
url += '/api/generate';
|
| 320 |
+
break;
|
| 321 |
+
case TEXTGEN_TYPES.OPENROUTER:
|
| 322 |
+
url += '/v1/chat/completions';
|
| 323 |
+
break;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
const args = {
|
| 327 |
+
method: 'POST',
|
| 328 |
+
body: JSON.stringify(request.body),
|
| 329 |
+
headers: { 'Content-Type': 'application/json' },
|
| 330 |
+
signal: controller.signal,
|
| 331 |
+
timeout: 0,
|
| 332 |
+
};
|
| 333 |
+
|
| 334 |
+
setAdditionalHeaders(request, args, baseUrl);
|
| 335 |
+
|
| 336 |
+
if (request.body.api_type === TEXTGEN_TYPES.TOGETHERAI) {
|
| 337 |
+
request.body = _.pickBy(request.body, (_, key) => TOGETHERAI_KEYS.includes(key));
|
| 338 |
+
args.body = JSON.stringify(request.body);
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
if (request.body.api_type === TEXTGEN_TYPES.INFERMATICAI) {
|
| 342 |
+
request.body = _.pickBy(request.body, (_, key) => INFERMATICAI_KEYS.includes(key));
|
| 343 |
+
args.body = JSON.stringify(request.body);
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
if (request.body.api_type === TEXTGEN_TYPES.FEATHERLESS) {
|
| 347 |
+
request.body = _.pickBy(request.body, (_, key) => FEATHERLESS_KEYS.includes(key));
|
| 348 |
+
args.body = JSON.stringify(request.body);
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
if (request.body.api_type === TEXTGEN_TYPES.DREAMGEN) {
|
| 352 |
+
args.body = JSON.stringify(request.body);
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
if (request.body.api_type === TEXTGEN_TYPES.GENERIC) {
|
| 356 |
+
request.body = _.pickBy(request.body, (_, key) => OPENAI_KEYS.includes(key));
|
| 357 |
+
if (Array.isArray(request.body.stop)) { request.body.stop = request.body.stop.slice(0, 4); }
|
| 358 |
+
args.body = JSON.stringify(request.body);
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
if (request.body.api_type === TEXTGEN_TYPES.OPENROUTER) {
|
| 362 |
+
if (Array.isArray(request.body.provider) && request.body.provider.length > 0) {
|
| 363 |
+
request.body.provider = {
|
| 364 |
+
allow_fallbacks: request.body.allow_fallbacks ?? true,
|
| 365 |
+
order: request.body.provider,
|
| 366 |
+
};
|
| 367 |
+
} else {
|
| 368 |
+
delete request.body.provider;
|
| 369 |
+
}
|
| 370 |
+
request.body = _.pickBy(request.body, (_, key) => OPENROUTER_KEYS.includes(key));
|
| 371 |
+
args.body = JSON.stringify(request.body);
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
if (request.body.api_type === TEXTGEN_TYPES.VLLM) {
|
| 375 |
+
request.body = _.pickBy(request.body, (_, key) => VLLM_KEYS.includes(key));
|
| 376 |
+
args.body = JSON.stringify(request.body);
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
if (request.body.api_type === TEXTGEN_TYPES.OLLAMA) {
|
| 380 |
+
const keepAlive = Number(getConfigValue('ollama.keepAlive', -1, 'number'));
|
| 381 |
+
const numBatch = Number(getConfigValue('ollama.batchSize', -1, 'number'));
|
| 382 |
+
if (numBatch > 0) {
|
| 383 |
+
request.body['num_batch'] = numBatch;
|
| 384 |
+
}
|
| 385 |
+
args.body = JSON.stringify({
|
| 386 |
+
model: request.body.model,
|
| 387 |
+
prompt: request.body.prompt,
|
| 388 |
+
stream: request.body.stream ?? false,
|
| 389 |
+
keep_alive: keepAlive,
|
| 390 |
+
raw: true,
|
| 391 |
+
options: _.pickBy(request.body, (_, key) => OLLAMA_KEYS.includes(key)),
|
| 392 |
+
});
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
if (request.body.api_type === TEXTGEN_TYPES.OLLAMA && request.body.stream) {
|
| 396 |
+
const stream = await fetch(url, args);
|
| 397 |
+
parseOllamaStream(stream, request, response);
|
| 398 |
+
} else if (request.body.stream) {
|
| 399 |
+
const completionsStream = await fetch(url, args);
|
| 400 |
+
// Pipe remote SSE stream to Express response
|
| 401 |
+
forwardFetchResponse(completionsStream, response);
|
| 402 |
+
}
|
| 403 |
+
else {
|
| 404 |
+
const completionsReply = await fetch(url, args);
|
| 405 |
+
|
| 406 |
+
if (completionsReply.ok) {
|
| 407 |
+
/** @type {any} */
|
| 408 |
+
const data = await completionsReply.json();
|
| 409 |
+
console.debug('Endpoint response:', data);
|
| 410 |
+
|
| 411 |
+
// Map InfermaticAI response to OAI completions format
|
| 412 |
+
if (apiType === TEXTGEN_TYPES.INFERMATICAI) {
|
| 413 |
+
data['choices'] = (data?.choices || []).map(choice => ({ text: choice?.message?.content || choice.text, logprobs: choice?.logprobs, index: choice?.index }));
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
return response.send(data);
|
| 417 |
+
} else {
|
| 418 |
+
const text = await completionsReply.text();
|
| 419 |
+
const errorBody = { error: true, status: completionsReply.status, response: text };
|
| 420 |
+
|
| 421 |
+
return !response.headersSent
|
| 422 |
+
? response.send(errorBody)
|
| 423 |
+
: response.end();
|
| 424 |
+
}
|
| 425 |
+
}
|
| 426 |
+
} catch (error) {
|
| 427 |
+
const status = error?.status ?? error?.code ?? 'UNKNOWN';
|
| 428 |
+
const text = error?.error ?? error?.statusText ?? error?.message ?? 'Unknown error on /generate endpoint';
|
| 429 |
+
let value = { error: true, status: status, response: text };
|
| 430 |
+
console.error('Endpoint error:', error);
|
| 431 |
+
|
| 432 |
+
return !response.headersSent
|
| 433 |
+
? response.send(value)
|
| 434 |
+
: response.end();
|
| 435 |
+
}
|
| 436 |
+
});
|
| 437 |
+
|
| 438 |
+
const ollama = express.Router();
|
| 439 |
+
|
| 440 |
+
ollama.post('/download', async function (request, response) {
|
| 441 |
+
try {
|
| 442 |
+
if (!request.body.name || !request.body.api_server) return response.sendStatus(400);
|
| 443 |
+
|
| 444 |
+
const name = request.body.name;
|
| 445 |
+
const url = String(request.body.api_server).replace(/\/$/, '');
|
| 446 |
+
console.debug('Pulling Ollama model:', name);
|
| 447 |
+
|
| 448 |
+
const fetchResponse = await fetch(`${url}/api/pull`, {
|
| 449 |
+
method: 'POST',
|
| 450 |
+
headers: { 'Content-Type': 'application/json' },
|
| 451 |
+
body: JSON.stringify({
|
| 452 |
+
name: name,
|
| 453 |
+
stream: false,
|
| 454 |
+
}),
|
| 455 |
+
});
|
| 456 |
+
|
| 457 |
+
if (!fetchResponse.ok) {
|
| 458 |
+
console.error('Download error:', fetchResponse.status, fetchResponse.statusText);
|
| 459 |
+
return response.status(500).send({ error: true });
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
console.debug('Ollama pull response:', await fetchResponse.json());
|
| 463 |
+
return response.send({ ok: true });
|
| 464 |
+
} catch (error) {
|
| 465 |
+
console.error(error);
|
| 466 |
+
return response.sendStatus(500);
|
| 467 |
+
}
|
| 468 |
+
});
|
| 469 |
+
|
| 470 |
+
ollama.post('/caption-image', async function (request, response) {
|
| 471 |
+
try {
|
| 472 |
+
if (!request.body.server_url || !request.body.model) {
|
| 473 |
+
return response.sendStatus(400);
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
console.debug('Ollama caption request:', request.body);
|
| 477 |
+
const baseUrl = trimV1(request.body.server_url);
|
| 478 |
+
|
| 479 |
+
const fetchResponse = await fetch(`${baseUrl}/api/generate`, {
|
| 480 |
+
method: 'POST',
|
| 481 |
+
headers: { 'Content-Type': 'application/json' },
|
| 482 |
+
body: JSON.stringify({
|
| 483 |
+
model: request.body.model,
|
| 484 |
+
prompt: request.body.prompt,
|
| 485 |
+
images: [request.body.image],
|
| 486 |
+
stream: false,
|
| 487 |
+
}),
|
| 488 |
+
});
|
| 489 |
+
|
| 490 |
+
if (!fetchResponse.ok) {
|
| 491 |
+
const errorText = await fetchResponse.text();
|
| 492 |
+
console.error('Ollama caption error:', fetchResponse.status, fetchResponse.statusText, errorText);
|
| 493 |
+
return response.status(500).send({ error: true });
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
/** @type {any} */
|
| 497 |
+
const data = await fetchResponse.json();
|
| 498 |
+
console.debug('Ollama caption response:', data);
|
| 499 |
+
|
| 500 |
+
const caption = data?.response || '';
|
| 501 |
+
|
| 502 |
+
if (!caption) {
|
| 503 |
+
console.error('Ollama caption is empty.');
|
| 504 |
+
return response.status(500).send({ error: true });
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
return response.send({ caption });
|
| 508 |
+
} catch (error) {
|
| 509 |
+
console.error(error);
|
| 510 |
+
return response.sendStatus(500);
|
| 511 |
+
}
|
| 512 |
+
});
|
| 513 |
+
|
| 514 |
+
const llamacpp = express.Router();
|
| 515 |
+
|
| 516 |
+
llamacpp.post('/props', async function (request, response) {
|
| 517 |
+
try {
|
| 518 |
+
if (!request.body.server_url) {
|
| 519 |
+
return response.sendStatus(400);
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
console.debug('LlamaCpp props request:', request.body);
|
| 523 |
+
const baseUrl = trimV1(request.body.server_url);
|
| 524 |
+
|
| 525 |
+
const fetchResponse = await fetch(`${baseUrl}/props`, {
|
| 526 |
+
method: 'GET',
|
| 527 |
+
});
|
| 528 |
+
|
| 529 |
+
if (!fetchResponse.ok) {
|
| 530 |
+
console.error('LlamaCpp props error:', fetchResponse.status, fetchResponse.statusText);
|
| 531 |
+
return response.status(500).send({ error: true });
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
const data = await fetchResponse.json();
|
| 535 |
+
console.debug('LlamaCpp props response:', data);
|
| 536 |
+
|
| 537 |
+
return response.send(data);
|
| 538 |
+
|
| 539 |
+
} catch (error) {
|
| 540 |
+
console.error(error);
|
| 541 |
+
return response.sendStatus(500);
|
| 542 |
+
}
|
| 543 |
+
});
|
| 544 |
+
|
| 545 |
+
llamacpp.post('/slots', async function (request, response) {
|
| 546 |
+
try {
|
| 547 |
+
if (!request.body.server_url) {
|
| 548 |
+
return response.sendStatus(400);
|
| 549 |
+
}
|
| 550 |
+
if (!/^(erase|info|restore|save)$/.test(request.body.action)) {
|
| 551 |
+
return response.sendStatus(400);
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
console.debug('LlamaCpp slots request:', request.body);
|
| 555 |
+
const baseUrl = trimV1(request.body.server_url);
|
| 556 |
+
|
| 557 |
+
let fetchResponse;
|
| 558 |
+
if (request.body.action === 'info') {
|
| 559 |
+
fetchResponse = await fetch(`${baseUrl}/slots`, {
|
| 560 |
+
method: 'GET',
|
| 561 |
+
});
|
| 562 |
+
} else {
|
| 563 |
+
if (!/^\d+$/.test(request.body.id_slot)) {
|
| 564 |
+
return response.sendStatus(400);
|
| 565 |
+
}
|
| 566 |
+
if (request.body.action !== 'erase' && !request.body.filename) {
|
| 567 |
+
return response.sendStatus(400);
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
fetchResponse = await fetch(`${baseUrl}/slots/${request.body.id_slot}?action=${request.body.action}`, {
|
| 571 |
+
method: 'POST',
|
| 572 |
+
headers: { 'Content-Type': 'application/json' },
|
| 573 |
+
body: JSON.stringify({
|
| 574 |
+
filename: request.body.action !== 'erase' ? `${request.body.filename}` : undefined,
|
| 575 |
+
}),
|
| 576 |
+
});
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
if (!fetchResponse.ok) {
|
| 580 |
+
console.error('LlamaCpp slots error:', fetchResponse.status, fetchResponse.statusText);
|
| 581 |
+
return response.status(500).send({ error: true });
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
const data = await fetchResponse.json();
|
| 585 |
+
console.debug('LlamaCpp slots response:', data);
|
| 586 |
+
|
| 587 |
+
return response.send(data);
|
| 588 |
+
|
| 589 |
+
} catch (error) {
|
| 590 |
+
console.error(error);
|
| 591 |
+
return response.sendStatus(500);
|
| 592 |
+
}
|
| 593 |
+
});
|
| 594 |
+
|
| 595 |
+
const tabby = express.Router();
|
| 596 |
+
|
| 597 |
+
tabby.post('/download', async function (request, response) {
|
| 598 |
+
try {
|
| 599 |
+
const baseUrl = String(request.body.api_server).replace(/\/$/, '');
|
| 600 |
+
|
| 601 |
+
const args = {
|
| 602 |
+
method: 'POST',
|
| 603 |
+
headers: { 'Content-Type': 'application/json' },
|
| 604 |
+
body: JSON.stringify(request.body),
|
| 605 |
+
timeout: 0,
|
| 606 |
+
};
|
| 607 |
+
|
| 608 |
+
setAdditionalHeaders(request, args, baseUrl);
|
| 609 |
+
|
| 610 |
+
// Check key permissions
|
| 611 |
+
const permissionResponse = await fetch(`${baseUrl}/v1/auth/permission`, {
|
| 612 |
+
headers: args.headers,
|
| 613 |
+
});
|
| 614 |
+
|
| 615 |
+
if (permissionResponse.ok) {
|
| 616 |
+
/** @type {any} */
|
| 617 |
+
const permissionJson = await permissionResponse.json();
|
| 618 |
+
|
| 619 |
+
if (permissionJson['permission'] !== 'admin') {
|
| 620 |
+
return response.status(403).send({ error: true });
|
| 621 |
+
}
|
| 622 |
+
} else {
|
| 623 |
+
console.error('API Permission error:', permissionResponse.status, permissionResponse.statusText);
|
| 624 |
+
return response.status(500).send({ error: true });
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
const fetchResponse = await fetch(`${baseUrl}/v1/download`, args);
|
| 628 |
+
|
| 629 |
+
if (!fetchResponse.ok) {
|
| 630 |
+
console.error('Download error:', fetchResponse.status, fetchResponse.statusText);
|
| 631 |
+
return response.status(500).send({ error: true });
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
return response.send({ ok: true });
|
| 635 |
+
} catch (error) {
|
| 636 |
+
console.error(error);
|
| 637 |
+
return response.sendStatus(500);
|
| 638 |
+
}
|
| 639 |
+
});
|
| 640 |
+
|
| 641 |
+
router.use('/ollama', ollama);
|
| 642 |
+
router.use('/llamacpp', llamacpp);
|
| 643 |
+
router.use('/tabby', tabby);
|
src/endpoints/backgrounds.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from 'node:fs';
|
| 2 |
+
import path from 'node:path';
|
| 3 |
+
|
| 4 |
+
import express from 'express';
|
| 5 |
+
import sanitize from 'sanitize-filename';
|
| 6 |
+
|
| 7 |
+
import { dimensions, invalidateThumbnail } from './thumbnails.js';
|
| 8 |
+
import { getImages } from '../util.js';
|
| 9 |
+
import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
|
| 10 |
+
|
| 11 |
+
export const router = express.Router();
|
| 12 |
+
|
| 13 |
+
router.post('/all', function (request, response) {
|
| 14 |
+
const images = getImages(request.user.directories.backgrounds);
|
| 15 |
+
const config = { width: dimensions.bg[0], height: dimensions.bg[1] };
|
| 16 |
+
response.json({ images, config });
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
router.post('/delete', getFileNameValidationFunction('bg'), function (request, response) {
|
| 20 |
+
if (!request.body) return response.sendStatus(400);
|
| 21 |
+
|
| 22 |
+
if (request.body.bg !== sanitize(request.body.bg)) {
|
| 23 |
+
console.error('Malicious bg name prevented');
|
| 24 |
+
return response.sendStatus(403);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const fileName = path.join(request.user.directories.backgrounds, sanitize(request.body.bg));
|
| 28 |
+
|
| 29 |
+
if (!fs.existsSync(fileName)) {
|
| 30 |
+
console.error('BG file not found');
|
| 31 |
+
return response.sendStatus(400);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
fs.unlinkSync(fileName);
|
| 35 |
+
invalidateThumbnail(request.user.directories, 'bg', request.body.bg);
|
| 36 |
+
return response.send('ok');
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
router.post('/rename', function (request, response) {
|
| 40 |
+
if (!request.body) return response.sendStatus(400);
|
| 41 |
+
|
| 42 |
+
const oldFileName = path.join(request.user.directories.backgrounds, sanitize(request.body.old_bg));
|
| 43 |
+
const newFileName = path.join(request.user.directories.backgrounds, sanitize(request.body.new_bg));
|
| 44 |
+
|
| 45 |
+
if (!fs.existsSync(oldFileName)) {
|
| 46 |
+
console.error('BG file not found');
|
| 47 |
+
return response.sendStatus(400);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
if (fs.existsSync(newFileName)) {
|
| 51 |
+
console.error('New BG file already exists');
|
| 52 |
+
return response.sendStatus(400);
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
fs.copyFileSync(oldFileName, newFileName);
|
| 56 |
+
fs.unlinkSync(oldFileName);
|
| 57 |
+
invalidateThumbnail(request.user.directories, 'bg', request.body.old_bg);
|
| 58 |
+
return response.send('ok');
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
router.post('/upload', function (request, response) {
|
| 62 |
+
if (!request.body || !request.file) return response.sendStatus(400);
|
| 63 |
+
|
| 64 |
+
const img_path = path.join(request.file.destination, request.file.filename);
|
| 65 |
+
const filename = request.file.originalname;
|
| 66 |
+
|
| 67 |
+
try {
|
| 68 |
+
fs.copyFileSync(img_path, path.join(request.user.directories.backgrounds, filename));
|
| 69 |
+
fs.unlinkSync(img_path);
|
| 70 |
+
invalidateThumbnail(request.user.directories, 'bg', filename);
|
| 71 |
+
response.send(filename);
|
| 72 |
+
} catch (err) {
|
| 73 |
+
console.error(err);
|
| 74 |
+
response.sendStatus(500);
|
| 75 |
+
}
|
| 76 |
+
});
|
src/endpoints/backups.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import express from 'express';
|
| 2 |
+
import fs, { promises as fsPromises } from 'node:fs';
|
| 3 |
+
import path from 'node:path';
|
| 4 |
+
import sanitize from 'sanitize-filename';
|
| 5 |
+
import { CHAT_BACKUPS_PREFIX, getChatInfo } from './chats.js';
|
| 6 |
+
|
| 7 |
+
export const router = express.Router();
|
| 8 |
+
|
| 9 |
+
router.post('/chat/get', async (request, response) => {
|
| 10 |
+
try {
|
| 11 |
+
const backupModels = [];
|
| 12 |
+
const backupFiles = await fsPromises
|
| 13 |
+
.readdir(request.user.directories.backups, { withFileTypes: true })
|
| 14 |
+
.then(d => d .filter(d => d.isFile() && path.extname(d.name) === '.jsonl' && d.name.startsWith(CHAT_BACKUPS_PREFIX)).map(d => d.name));
|
| 15 |
+
|
| 16 |
+
for (const name of backupFiles) {
|
| 17 |
+
const filePath = path.join(request.user.directories.backups, name);
|
| 18 |
+
const info = await getChatInfo(filePath);
|
| 19 |
+
if (!info || !info.file_name) {
|
| 20 |
+
continue;
|
| 21 |
+
}
|
| 22 |
+
backupModels.push(info);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
return response.json(backupModels);
|
| 26 |
+
} catch (error) {
|
| 27 |
+
console.error(error);
|
| 28 |
+
return response.sendStatus(500);
|
| 29 |
+
}
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
router.post('/chat/delete', async (request, response) => {
|
| 33 |
+
try {
|
| 34 |
+
const { name } = request.body;
|
| 35 |
+
const filePath = path.join(request.user.directories.backups, sanitize(name));
|
| 36 |
+
|
| 37 |
+
if (!path.parse(filePath).base.startsWith(CHAT_BACKUPS_PREFIX)) {
|
| 38 |
+
console.warn('Attempt to delete non-chat backup file:', name);
|
| 39 |
+
return response.sendStatus(400);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
if (!fs.existsSync(filePath)) {
|
| 43 |
+
return response.sendStatus(404);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
await fsPromises.unlink(filePath);
|
| 47 |
+
return response.sendStatus(200);
|
| 48 |
+
}
|
| 49 |
+
catch (error) {
|
| 50 |
+
console.error(error);
|
| 51 |
+
return response.sendStatus(500);
|
| 52 |
+
}
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
router.post('/chat/download', async (request, response) => {
|
| 56 |
+
try {
|
| 57 |
+
const { name } = request.body;
|
| 58 |
+
const filePath = path.join(request.user.directories.backups, sanitize(name));
|
| 59 |
+
|
| 60 |
+
if (!path.parse(filePath).base.startsWith(CHAT_BACKUPS_PREFIX)) {
|
| 61 |
+
console.warn('Attempt to download non-chat backup file:', name);
|
| 62 |
+
return response.sendStatus(400);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
if (!fs.existsSync(filePath)) {
|
| 66 |
+
return response.sendStatus(404);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
return response.download(filePath);
|
| 70 |
+
}
|
| 71 |
+
catch (error) {
|
| 72 |
+
console.error(error);
|
| 73 |
+
return response.sendStatus(500);
|
| 74 |
+
}
|
| 75 |
+
});
|
src/endpoints/caption.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import express from 'express';
|
| 2 |
+
import { getPipeline, getRawImage } from '../transformers.js';
|
| 3 |
+
|
| 4 |
+
export const router = express.Router();
|
| 5 |
+
|
| 6 |
+
const TASK = 'image-to-text';
|
| 7 |
+
|
| 8 |
+
router.post('/', async (req, res) => {
|
| 9 |
+
try {
|
| 10 |
+
const { image } = req.body;
|
| 11 |
+
|
| 12 |
+
const rawImage = await getRawImage(image);
|
| 13 |
+
|
| 14 |
+
if (!rawImage) {
|
| 15 |
+
console.warn('Failed to parse captioned image');
|
| 16 |
+
return res.sendStatus(400);
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const pipe = await getPipeline(TASK);
|
| 20 |
+
const result = await pipe(rawImage);
|
| 21 |
+
const text = result[0].generated_text;
|
| 22 |
+
console.info('Image caption:', text);
|
| 23 |
+
|
| 24 |
+
return res.json({ caption: text });
|
| 25 |
+
} catch (error) {
|
| 26 |
+
console.error(error);
|
| 27 |
+
return res.sendStatus(500);
|
| 28 |
+
}
|
| 29 |
+
});
|
src/endpoints/characters.js
ADDED
|
@@ -0,0 +1,1547 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import path from 'node:path';
|
| 2 |
+
import fs from 'node:fs';
|
| 3 |
+
import { promises as fsPromises } from 'node:fs';
|
| 4 |
+
import { Buffer } from 'node:buffer';
|
| 5 |
+
|
| 6 |
+
import express from 'express';
|
| 7 |
+
import sanitize from 'sanitize-filename';
|
| 8 |
+
import { sync as writeFileAtomicSync } from 'write-file-atomic';
|
| 9 |
+
import yaml from 'yaml';
|
| 10 |
+
import _ from 'lodash';
|
| 11 |
+
import mime from 'mime-types';
|
| 12 |
+
import { Jimp, JimpMime } from '../jimp.js';
|
| 13 |
+
import storage from 'node-persist';
|
| 14 |
+
|
| 15 |
+
import { AVATAR_WIDTH, AVATAR_HEIGHT, DEFAULT_AVATAR_PATH } from '../constants.js';
|
| 16 |
+
import { default as validateAvatarUrlMiddleware, getFileNameValidationFunction } from '../middleware/validateFileName.js';
|
| 17 |
+
import { deepMerge, humanizedDateTime, tryParse, MemoryLimitedMap, getConfigValue, mutateJsonString, clientRelativePath, getUniqueName, sanitizeSafeCharacterReplacements } from '../util.js';
|
| 18 |
+
import { TavernCardValidator } from '../validator/TavernCardValidator.js';
|
| 19 |
+
import { parse, read, write } from '../character-card-parser.js';
|
| 20 |
+
import { readWorldInfoFile } from './worldinfo.js';
|
| 21 |
+
import { invalidateThumbnail } from './thumbnails.js';
|
| 22 |
+
import { importRisuSprites } from './sprites.js';
|
| 23 |
+
import { getUserDirectories } from '../users.js';
|
| 24 |
+
import { getChatInfo } from './chats.js';
|
| 25 |
+
import { ByafParser } from '../byaf.js';
|
| 26 |
+
import { CharXParser, persistCharXAssets } from '../charx.js';
|
| 27 |
+
import cacheBuster from '../middleware/cacheBuster.js';
|
| 28 |
+
|
| 29 |
+
// With 100 MB limit it would take roughly 3000 characters to reach this limit
|
| 30 |
+
const memoryCacheCapacity = getConfigValue('performance.memoryCacheCapacity', '100mb');
|
| 31 |
+
const memoryCache = new MemoryLimitedMap(memoryCacheCapacity);
|
| 32 |
+
// Some Android devices require tighter memory management
|
| 33 |
+
const isAndroid = process.platform === 'android';
|
| 34 |
+
// Use shallow character data for the character list
|
| 35 |
+
const useShallowCharacters = !!getConfigValue('performance.lazyLoadCharacters', false, 'boolean');
|
| 36 |
+
const useDiskCache = !!getConfigValue('performance.useDiskCache', true, 'boolean');
|
| 37 |
+
|
| 38 |
+
class DiskCache {
|
| 39 |
+
/**
|
| 40 |
+
* @type {string}
|
| 41 |
+
* @readonly
|
| 42 |
+
*/
|
| 43 |
+
static DIRECTORY = 'characters';
|
| 44 |
+
|
| 45 |
+
/**
|
| 46 |
+
* @type {number}
|
| 47 |
+
* @readonly
|
| 48 |
+
*/
|
| 49 |
+
static SYNC_INTERVAL = 5 * 60 * 1000;
|
| 50 |
+
|
| 51 |
+
/** @type {import('node-persist').LocalStorage} */
|
| 52 |
+
#instance;
|
| 53 |
+
|
| 54 |
+
/** @type {NodeJS.Timeout} */
|
| 55 |
+
#syncInterval;
|
| 56 |
+
|
| 57 |
+
/**
|
| 58 |
+
* Queue of user handles to sync.
|
| 59 |
+
* @type {Set<string>}
|
| 60 |
+
* @readonly
|
| 61 |
+
*/
|
| 62 |
+
syncQueue = new Set();
|
| 63 |
+
|
| 64 |
+
/**
|
| 65 |
+
* Path to the cache directory.
|
| 66 |
+
* @returns {string}
|
| 67 |
+
*/
|
| 68 |
+
get cachePath() {
|
| 69 |
+
return path.join(globalThis.DATA_ROOT, '_cache', DiskCache.DIRECTORY);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/**
|
| 73 |
+
* Returns the list of hashed keys in the cache.
|
| 74 |
+
* @returns {string[]}
|
| 75 |
+
*/
|
| 76 |
+
get hashedKeys() {
|
| 77 |
+
return fs.readdirSync(this.cachePath);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
/**
|
| 81 |
+
* Processes the synchronization queue.
|
| 82 |
+
* @returns {Promise<void>}
|
| 83 |
+
*/
|
| 84 |
+
async #syncCacheEntries() {
|
| 85 |
+
try {
|
| 86 |
+
if (!useDiskCache || this.syncQueue.size === 0) {
|
| 87 |
+
return;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
const directories = [...this.syncQueue].map(entry => getUserDirectories(entry));
|
| 91 |
+
this.syncQueue.clear();
|
| 92 |
+
|
| 93 |
+
await this.verify(directories);
|
| 94 |
+
} catch (error) {
|
| 95 |
+
console.error('Error while synchronizing cache entries:', error);
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
/**
|
| 100 |
+
* Gets the disk cache instance.
|
| 101 |
+
* @returns {Promise<import('node-persist').LocalStorage>}
|
| 102 |
+
*/
|
| 103 |
+
async instance() {
|
| 104 |
+
if (this.#instance) {
|
| 105 |
+
return this.#instance;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
this.#instance = storage.create({
|
| 109 |
+
dir: this.cachePath,
|
| 110 |
+
ttl: false,
|
| 111 |
+
forgiveParseErrors: true,
|
| 112 |
+
expiredInterval: 0,
|
| 113 |
+
// @ts-ignore
|
| 114 |
+
maxFileDescriptors: 100,
|
| 115 |
+
});
|
| 116 |
+
await this.#instance.init();
|
| 117 |
+
this.#syncInterval = setInterval(this.#syncCacheEntries.bind(this), DiskCache.SYNC_INTERVAL);
|
| 118 |
+
return this.#instance;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
/**
|
| 122 |
+
* Verifies disk cache size and prunes it if necessary.
|
| 123 |
+
* @param {import('../users.js').UserDirectoryList[]} directoriesList List of user directories
|
| 124 |
+
* @returns {Promise<void>}
|
| 125 |
+
*/
|
| 126 |
+
async verify(directoriesList) {
|
| 127 |
+
try {
|
| 128 |
+
if (!useDiskCache) {
|
| 129 |
+
return;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
const cache = await this.instance();
|
| 133 |
+
const validKeys = new Set();
|
| 134 |
+
for (const dir of directoriesList) {
|
| 135 |
+
const files = fs.readdirSync(dir.characters, { withFileTypes: true });
|
| 136 |
+
for (const file of files.filter(f => f.isFile() && path.extname(f.name) === '.png')) {
|
| 137 |
+
const filePath = path.join(dir.characters, file.name);
|
| 138 |
+
const cacheKey = getCacheKey(filePath);
|
| 139 |
+
validKeys.add(path.parse(cache.getDatumPath(cacheKey)).base);
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
for (const key of this.hashedKeys) {
|
| 143 |
+
if (!validKeys.has(key)) {
|
| 144 |
+
await cache.removeItem(key);
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
} catch (error) {
|
| 148 |
+
console.error('Error while verifying disk cache:', error);
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
dispose() {
|
| 153 |
+
if (this.#syncInterval) {
|
| 154 |
+
clearInterval(this.#syncInterval);
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
export const diskCache = new DiskCache();
|
| 160 |
+
|
| 161 |
+
/**
|
| 162 |
+
* Gets the cache key for the specified image file.
|
| 163 |
+
* @param {string} inputFile - Path to the image file
|
| 164 |
+
* @returns {string} - Cache key
|
| 165 |
+
*/
|
| 166 |
+
function getCacheKey(inputFile) {
|
| 167 |
+
if (fs.existsSync(inputFile)) {
|
| 168 |
+
const stat = fs.statSync(inputFile);
|
| 169 |
+
return `${inputFile}-${stat.mtimeMs}`;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
return inputFile;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
/**
|
| 176 |
+
* Reads the character card from the specified image file.
|
| 177 |
+
* @param {string} inputFile - Path to the image file
|
| 178 |
+
* @param {string} inputFormat - 'png'
|
| 179 |
+
* @returns {Promise<string | undefined>} - Character card data
|
| 180 |
+
*/
|
| 181 |
+
async function readCharacterData(inputFile, inputFormat = 'png') {
|
| 182 |
+
const cacheKey = getCacheKey(inputFile);
|
| 183 |
+
if (memoryCache.has(cacheKey)) {
|
| 184 |
+
return memoryCache.get(cacheKey);
|
| 185 |
+
}
|
| 186 |
+
if (useDiskCache) {
|
| 187 |
+
try {
|
| 188 |
+
const cache = await diskCache.instance();
|
| 189 |
+
const cachedData = await cache.getItem(cacheKey);
|
| 190 |
+
if (cachedData) {
|
| 191 |
+
return cachedData;
|
| 192 |
+
}
|
| 193 |
+
} catch (error) {
|
| 194 |
+
console.warn('Error while reading from disk cache:', error);
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
const result = await parse(inputFile, inputFormat);
|
| 199 |
+
!isAndroid && memoryCache.set(cacheKey, result);
|
| 200 |
+
if (useDiskCache) {
|
| 201 |
+
try {
|
| 202 |
+
const cache = await diskCache.instance();
|
| 203 |
+
await cache.setItem(cacheKey, result);
|
| 204 |
+
} catch (error) {
|
| 205 |
+
console.warn('Error while writing to disk cache:', error);
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
return result;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
/**
|
| 212 |
+
* Writes the character card to the specified image file.
|
| 213 |
+
* @param {string|Buffer} inputFile - Path to the image file or image buffer
|
| 214 |
+
* @param {string} data - Character card data
|
| 215 |
+
* @param {string} outputFile - Target image file name
|
| 216 |
+
* @param {import('express').Request} request - Express request obejct
|
| 217 |
+
* @param {Crop|undefined} crop - Crop parameters
|
| 218 |
+
* @returns {Promise<boolean>} - True if the operation was successful
|
| 219 |
+
*/
|
| 220 |
+
async function writeCharacterData(inputFile, data, outputFile, request, crop = undefined) {
|
| 221 |
+
try {
|
| 222 |
+
// Reset the cache
|
| 223 |
+
for (const key of memoryCache.keys()) {
|
| 224 |
+
if (Buffer.isBuffer(inputFile)) {
|
| 225 |
+
break;
|
| 226 |
+
}
|
| 227 |
+
if (key.startsWith(inputFile)) {
|
| 228 |
+
memoryCache.delete(key);
|
| 229 |
+
break;
|
| 230 |
+
}
|
| 231 |
+
}
|
| 232 |
+
if (useDiskCache && !Buffer.isBuffer(inputFile)) {
|
| 233 |
+
diskCache.syncQueue.add(request.user.profile.handle);
|
| 234 |
+
}
|
| 235 |
+
/**
|
| 236 |
+
* Read the image, resize, and save it as a PNG into the buffer.
|
| 237 |
+
* @returns {Promise<Buffer>} Image buffer
|
| 238 |
+
*/
|
| 239 |
+
async function getInputImage() {
|
| 240 |
+
try {
|
| 241 |
+
if (Buffer.isBuffer(inputFile)) {
|
| 242 |
+
return await parseImageBuffer(inputFile, crop);
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
return await tryReadImage(inputFile, crop);
|
| 246 |
+
} catch (error) {
|
| 247 |
+
const message = Buffer.isBuffer(inputFile) ? 'Failed to read image buffer.' : `Failed to read image: ${inputFile}.`;
|
| 248 |
+
console.warn(message, 'Using a fallback image.', error);
|
| 249 |
+
return await fs.promises.readFile(DEFAULT_AVATAR_PATH);
|
| 250 |
+
}
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
const inputImage = await getInputImage();
|
| 254 |
+
|
| 255 |
+
// Get the chunks
|
| 256 |
+
const outputImage = write(inputImage, data);
|
| 257 |
+
const outputImagePath = path.join(request.user.directories.characters, `${outputFile}.png`);
|
| 258 |
+
|
| 259 |
+
writeFileAtomicSync(outputImagePath, outputImage);
|
| 260 |
+
return true;
|
| 261 |
+
} catch (err) {
|
| 262 |
+
console.error(err);
|
| 263 |
+
return false;
|
| 264 |
+
}
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
/**
|
| 268 |
+
* @typedef {Object} Crop
|
| 269 |
+
* @property {number} x X-coordinate
|
| 270 |
+
* @property {number} y Y-coordinate
|
| 271 |
+
* @property {number} width Width
|
| 272 |
+
* @property {number} height Height
|
| 273 |
+
* @property {boolean} want_resize Resize the image to the standard avatar size
|
| 274 |
+
*/
|
| 275 |
+
|
| 276 |
+
/**
|
| 277 |
+
* Applies avatar crop and resize operations to an image.
|
| 278 |
+
* I couldn't fix the type issue, so the first argument has {any} type.
|
| 279 |
+
* @param {object} jimp Jimp image instance
|
| 280 |
+
* @param {Crop|undefined} [crop] Crop parameters
|
| 281 |
+
* @returns {Promise<Buffer>} Processed image buffer
|
| 282 |
+
*/
|
| 283 |
+
export async function applyAvatarCropResize(jimp, crop) {
|
| 284 |
+
if (!(jimp instanceof Jimp)) {
|
| 285 |
+
throw new TypeError('Expected a Jimp instance');
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
const image = /** @type {InstanceType<typeof Jimp>} */ (jimp);
|
| 289 |
+
let finalWidth = image.bitmap.width, finalHeight = image.bitmap.height;
|
| 290 |
+
|
| 291 |
+
// Apply crop if defined
|
| 292 |
+
if (typeof crop == 'object' && [crop.x, crop.y, crop.width, crop.height].every(x => typeof x === 'number')) {
|
| 293 |
+
image.crop({ x: crop.x, y: crop.y, w: crop.width, h: crop.height });
|
| 294 |
+
// Apply standard resize if requested
|
| 295 |
+
if (crop.want_resize) {
|
| 296 |
+
finalWidth = AVATAR_WIDTH;
|
| 297 |
+
finalHeight = AVATAR_HEIGHT;
|
| 298 |
+
} else {
|
| 299 |
+
finalWidth = crop.width;
|
| 300 |
+
finalHeight = crop.height;
|
| 301 |
+
}
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
image.cover({ w: finalWidth, h: finalHeight });
|
| 305 |
+
return await image.getBuffer(JimpMime.png);
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
/**
|
| 309 |
+
* Parses an image buffer and applies crop if defined.
|
| 310 |
+
* @param {Buffer} buffer Buffer of the image
|
| 311 |
+
* @param {Crop|undefined} [crop] Crop parameters
|
| 312 |
+
* @returns {Promise<Buffer>} Image buffer
|
| 313 |
+
*/
|
| 314 |
+
async function parseImageBuffer(buffer, crop) {
|
| 315 |
+
const image = await Jimp.fromBuffer(buffer);
|
| 316 |
+
return await applyAvatarCropResize(image, crop);
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
/**
|
| 320 |
+
* Reads an image file and applies crop if defined.
|
| 321 |
+
* @param {string} imgPath Path to the image file
|
| 322 |
+
* @param {Crop|undefined} crop Crop parameters
|
| 323 |
+
* @returns {Promise<Buffer>} Image buffer
|
| 324 |
+
*/
|
| 325 |
+
async function tryReadImage(imgPath, crop) {
|
| 326 |
+
try {
|
| 327 |
+
const rawImg = await Jimp.read(imgPath);
|
| 328 |
+
return await applyAvatarCropResize(rawImg, crop);
|
| 329 |
+
}
|
| 330 |
+
// If it's an unsupported type of image (APNG) - just read the file as buffer
|
| 331 |
+
catch (error) {
|
| 332 |
+
console.error(`Failed to read image: ${imgPath}`, error);
|
| 333 |
+
return fs.readFileSync(imgPath);
|
| 334 |
+
}
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
/**
|
| 338 |
+
* calculateChatSize - Calculates the total chat size for a given character.
|
| 339 |
+
*
|
| 340 |
+
* @param {string} charDir The directory where the chats are stored.
|
| 341 |
+
* @return { {chatSize: number, dateLastChat: number} } The total chat size.
|
| 342 |
+
*/
|
| 343 |
+
const calculateChatSize = (charDir) => {
|
| 344 |
+
let chatSize = 0;
|
| 345 |
+
let dateLastChat = 0;
|
| 346 |
+
|
| 347 |
+
if (fs.existsSync(charDir)) {
|
| 348 |
+
const chats = fs.readdirSync(charDir);
|
| 349 |
+
if (Array.isArray(chats) && chats.length) {
|
| 350 |
+
for (const chat of chats) {
|
| 351 |
+
const chatStat = fs.statSync(path.join(charDir, chat));
|
| 352 |
+
chatSize += chatStat.size;
|
| 353 |
+
dateLastChat = Math.max(dateLastChat, chatStat.mtimeMs);
|
| 354 |
+
}
|
| 355 |
+
}
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
return { chatSize, dateLastChat };
|
| 359 |
+
};
|
| 360 |
+
|
| 361 |
+
// Calculate the total string length of the data object
|
| 362 |
+
const calculateDataSize = (data) => {
|
| 363 |
+
return typeof data === 'object' ? Object.values(data).reduce((acc, val) => acc + String(val).length, 0) : 0;
|
| 364 |
+
};
|
| 365 |
+
|
| 366 |
+
/**
|
| 367 |
+
* Only get fields that are used to display the character list.
|
| 368 |
+
* @param {object} character Character object
|
| 369 |
+
* @returns {{shallow: true, [key: string]: any}} Shallow character
|
| 370 |
+
*/
|
| 371 |
+
const toShallow = (character) => {
|
| 372 |
+
return {
|
| 373 |
+
shallow: true,
|
| 374 |
+
name: character.name,
|
| 375 |
+
avatar: character.avatar,
|
| 376 |
+
chat: character.chat,
|
| 377 |
+
fav: character.fav,
|
| 378 |
+
date_added: character.date_added,
|
| 379 |
+
create_date: character.create_date,
|
| 380 |
+
date_last_chat: character.date_last_chat,
|
| 381 |
+
chat_size: character.chat_size,
|
| 382 |
+
data_size: character.data_size,
|
| 383 |
+
tags: character.tags,
|
| 384 |
+
data: {
|
| 385 |
+
name: _.get(character, 'data.name', ''),
|
| 386 |
+
character_version: _.get(character, 'data.character_version', ''),
|
| 387 |
+
creator: _.get(character, 'data.creator', ''),
|
| 388 |
+
creator_notes: _.get(character, 'data.creator_notes', ''),
|
| 389 |
+
tags: _.get(character, 'data.tags', []),
|
| 390 |
+
extensions: {
|
| 391 |
+
fav: _.get(character, 'data.extensions.fav', false),
|
| 392 |
+
},
|
| 393 |
+
},
|
| 394 |
+
};
|
| 395 |
+
};
|
| 396 |
+
|
| 397 |
+
/**
|
| 398 |
+
* processCharacter - Process a given character, read its data and calculate its statistics.
|
| 399 |
+
*
|
| 400 |
+
* @param {string} item The name of the character.
|
| 401 |
+
* @param {import('../users.js').UserDirectoryList} directories User directories
|
| 402 |
+
* @param {object} options Options for the character processing
|
| 403 |
+
* @param {boolean} options.shallow If true, only return the core character's metadata
|
| 404 |
+
* @return {Promise<object>} A Promise that resolves when the character processing is done.
|
| 405 |
+
*/
|
| 406 |
+
const processCharacter = async (item, directories, { shallow }) => {
|
| 407 |
+
try {
|
| 408 |
+
const imgFile = path.join(directories.characters, item);
|
| 409 |
+
const imgData = await readCharacterData(imgFile);
|
| 410 |
+
if (imgData === undefined) throw new Error('Failed to read character file');
|
| 411 |
+
|
| 412 |
+
let jsonObject = getCharaCardV2(JSON.parse(imgData), directories, false);
|
| 413 |
+
jsonObject.avatar = item;
|
| 414 |
+
const character = jsonObject;
|
| 415 |
+
character['json_data'] = imgData;
|
| 416 |
+
const charStat = fs.statSync(path.join(directories.characters, item));
|
| 417 |
+
character['date_added'] = charStat.ctimeMs;
|
| 418 |
+
character['create_date'] = jsonObject['create_date'] || new Date(Math.round(charStat.ctimeMs)).toISOString();
|
| 419 |
+
const chatsDirectory = path.join(directories.chats, item.replace('.png', ''));
|
| 420 |
+
|
| 421 |
+
const { chatSize, dateLastChat } = calculateChatSize(chatsDirectory);
|
| 422 |
+
character['chat_size'] = chatSize;
|
| 423 |
+
character['date_last_chat'] = dateLastChat;
|
| 424 |
+
character['data_size'] = calculateDataSize(jsonObject?.data);
|
| 425 |
+
return shallow ? toShallow(character) : character;
|
| 426 |
+
}
|
| 427 |
+
catch (err) {
|
| 428 |
+
console.error(`Could not process character: ${item}`);
|
| 429 |
+
|
| 430 |
+
if (err instanceof SyntaxError) {
|
| 431 |
+
console.error(`${item} does not contain a valid JSON object.`);
|
| 432 |
+
} else {
|
| 433 |
+
console.error('An unexpected error occurred: ', err);
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
return {
|
| 437 |
+
date_added: 0,
|
| 438 |
+
date_last_chat: 0,
|
| 439 |
+
chat_size: 0,
|
| 440 |
+
};
|
| 441 |
+
}
|
| 442 |
+
};
|
| 443 |
+
|
| 444 |
+
/**
|
| 445 |
+
* Convert a character object to Spec V2 format.
|
| 446 |
+
* @param {object} jsonObject Character object
|
| 447 |
+
* @param {import('../users.js').UserDirectoryList} directories User directories
|
| 448 |
+
* @param {boolean} hoistDate Will set the chat and create_date fields to the current date if they are missing
|
| 449 |
+
* @returns {object} Character object in Spec V2 format
|
| 450 |
+
*/
|
| 451 |
+
function getCharaCardV2(jsonObject, directories, hoistDate = true) {
|
| 452 |
+
if (jsonObject.spec === undefined) {
|
| 453 |
+
jsonObject = convertToV2(jsonObject, directories);
|
| 454 |
+
|
| 455 |
+
if (hoistDate && !jsonObject.create_date) {
|
| 456 |
+
jsonObject.create_date = new Date().toISOString();
|
| 457 |
+
}
|
| 458 |
+
} else {
|
| 459 |
+
jsonObject = readFromV2(jsonObject);
|
| 460 |
+
}
|
| 461 |
+
return jsonObject;
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
/**
|
| 465 |
+
* Convert a character object to Spec V2 format.
|
| 466 |
+
* @param {object} char Character object
|
| 467 |
+
* @param {import('../users.js').UserDirectoryList} directories User directories
|
| 468 |
+
* @returns {object} Character object in Spec V2 format
|
| 469 |
+
*/
|
| 470 |
+
function convertToV2(char, directories) {
|
| 471 |
+
// Simulate incoming data from frontend form
|
| 472 |
+
const result = charaFormatData({
|
| 473 |
+
json_data: JSON.stringify(char),
|
| 474 |
+
ch_name: char.name,
|
| 475 |
+
description: char.description,
|
| 476 |
+
personality: char.personality,
|
| 477 |
+
scenario: char.scenario,
|
| 478 |
+
first_mes: char.first_mes,
|
| 479 |
+
mes_example: char.mes_example,
|
| 480 |
+
creator_notes: char.creatorcomment,
|
| 481 |
+
talkativeness: char.talkativeness,
|
| 482 |
+
fav: char.fav,
|
| 483 |
+
creator: char.creator,
|
| 484 |
+
tags: char.tags,
|
| 485 |
+
depth_prompt_prompt: char.depth_prompt_prompt,
|
| 486 |
+
depth_prompt_depth: char.depth_prompt_depth,
|
| 487 |
+
depth_prompt_role: char.depth_prompt_role,
|
| 488 |
+
}, directories);
|
| 489 |
+
|
| 490 |
+
result.chat = char.chat ?? `${char.name} - ${humanizedDateTime()}`;
|
| 491 |
+
result.create_date = char.create_date;
|
| 492 |
+
|
| 493 |
+
return result;
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
/**
|
| 497 |
+
* Removes fields that are not meant to be shared.
|
| 498 |
+
*/
|
| 499 |
+
function unsetPrivateFields(char) {
|
| 500 |
+
_.set(char, 'fav', false);
|
| 501 |
+
_.set(char, 'data.extensions.fav', false);
|
| 502 |
+
_.unset(char, 'chat');
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
function readFromV2(char) {
|
| 506 |
+
if (_.isUndefined(char.data)) {
|
| 507 |
+
console.warn(`Char ${char['name']} has Spec v2 data missing`);
|
| 508 |
+
return char;
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
// If 'json_data' was already saved, don't let it propagate
|
| 512 |
+
_.unset(char, 'json_data');
|
| 513 |
+
|
| 514 |
+
const fieldMappings = {
|
| 515 |
+
name: 'name',
|
| 516 |
+
description: 'description',
|
| 517 |
+
personality: 'personality',
|
| 518 |
+
scenario: 'scenario',
|
| 519 |
+
first_mes: 'first_mes',
|
| 520 |
+
mes_example: 'mes_example',
|
| 521 |
+
talkativeness: 'extensions.talkativeness',
|
| 522 |
+
fav: 'extensions.fav',
|
| 523 |
+
tags: 'tags',
|
| 524 |
+
};
|
| 525 |
+
|
| 526 |
+
_.forEach(fieldMappings, (v2Path, charField) => {
|
| 527 |
+
//console.info(`Migrating field: ${charField} from ${v2Path}`);
|
| 528 |
+
const v2Value = _.get(char.data, v2Path);
|
| 529 |
+
if (_.isUndefined(v2Value)) {
|
| 530 |
+
let defaultValue = undefined;
|
| 531 |
+
|
| 532 |
+
// Backfill default values for missing ST extension fields
|
| 533 |
+
if (v2Path === 'extensions.talkativeness') {
|
| 534 |
+
defaultValue = 0.5;
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
if (v2Path === 'extensions.fav') {
|
| 538 |
+
defaultValue = false;
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
if (!_.isUndefined(defaultValue)) {
|
| 542 |
+
//console.warn(`Spec v2 extension data missing for field: ${charField}, using default value: ${defaultValue}`);
|
| 543 |
+
char[charField] = defaultValue;
|
| 544 |
+
} else {
|
| 545 |
+
console.warn(`Char ${char['name']} has Spec v2 data missing for unknown field: ${charField}`);
|
| 546 |
+
return;
|
| 547 |
+
}
|
| 548 |
+
}
|
| 549 |
+
if (!_.isUndefined(char[charField]) && !_.isUndefined(v2Value) && String(char[charField]) !== String(v2Value)) {
|
| 550 |
+
console.warn(`Char ${char['name']} has Spec v2 data mismatch with Spec v1 for field: ${charField}`, char[charField], v2Value);
|
| 551 |
+
}
|
| 552 |
+
char[charField] = v2Value;
|
| 553 |
+
});
|
| 554 |
+
|
| 555 |
+
char['chat'] = char['chat'] ?? `${char.name} - ${humanizedDateTime()}`;
|
| 556 |
+
|
| 557 |
+
return char;
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
/**
|
| 561 |
+
* Format character data to Spec V2 format.
|
| 562 |
+
* @param {object} data Character data
|
| 563 |
+
* @param {import('../users.js').UserDirectoryList} directories User directories
|
| 564 |
+
* @returns
|
| 565 |
+
*/
|
| 566 |
+
function charaFormatData(data, directories) {
|
| 567 |
+
// This is supposed to save all the foreign keys that ST doesn't care about
|
| 568 |
+
const char = tryParse(data.json_data) || {};
|
| 569 |
+
|
| 570 |
+
// Prevent erroneous 'json_data' recursive saving
|
| 571 |
+
_.unset(char, 'json_data');
|
| 572 |
+
|
| 573 |
+
// Checks if data.alternate_greetings is an array, a string, or neither, and acts accordingly. (expected to be an array of strings)
|
| 574 |
+
const getAlternateGreetings = data => {
|
| 575 |
+
if (Array.isArray(data.alternate_greetings)) return data.alternate_greetings;
|
| 576 |
+
if (typeof data.alternate_greetings === 'string') return [data.alternate_greetings];
|
| 577 |
+
return [];
|
| 578 |
+
};
|
| 579 |
+
|
| 580 |
+
// Spec V1 fields
|
| 581 |
+
_.set(char, 'name', data.ch_name);
|
| 582 |
+
_.set(char, 'description', data.description || '');
|
| 583 |
+
_.set(char, 'personality', data.personality || '');
|
| 584 |
+
_.set(char, 'scenario', data.scenario || '');
|
| 585 |
+
_.set(char, 'first_mes', data.first_mes || '');
|
| 586 |
+
_.set(char, 'mes_example', data.mes_example || '');
|
| 587 |
+
|
| 588 |
+
// Old ST extension fields (for backward compatibility, will be deprecated)
|
| 589 |
+
_.set(char, 'creatorcomment', data.creator_notes || '');
|
| 590 |
+
_.set(char, 'avatar', 'none');
|
| 591 |
+
_.set(char, 'chat', data.ch_name + ' - ' + humanizedDateTime());
|
| 592 |
+
_.set(char, 'talkativeness', data.talkativeness || 0.5);
|
| 593 |
+
_.set(char, 'fav', data.fav == 'true');
|
| 594 |
+
_.set(char, 'tags', typeof data.tags == 'string' ? (data.tags.split(',').map(x => x.trim()).filter(x => x)) : data.tags || []);
|
| 595 |
+
|
| 596 |
+
// Spec V2 fields
|
| 597 |
+
_.set(char, 'spec', 'chara_card_v2');
|
| 598 |
+
_.set(char, 'spec_version', '2.0');
|
| 599 |
+
_.set(char, 'data.name', data.ch_name);
|
| 600 |
+
_.set(char, 'data.description', data.description || '');
|
| 601 |
+
_.set(char, 'data.personality', data.personality || '');
|
| 602 |
+
_.set(char, 'data.scenario', data.scenario || '');
|
| 603 |
+
_.set(char, 'data.first_mes', data.first_mes || '');
|
| 604 |
+
_.set(char, 'data.mes_example', data.mes_example || '');
|
| 605 |
+
|
| 606 |
+
// New V2 fields
|
| 607 |
+
_.set(char, 'data.creator_notes', data.creator_notes || '');
|
| 608 |
+
_.set(char, 'data.system_prompt', data.system_prompt || '');
|
| 609 |
+
_.set(char, 'data.post_history_instructions', data.post_history_instructions || '');
|
| 610 |
+
_.set(char, 'data.tags', typeof data.tags == 'string' ? (data.tags.split(',').map(x => x.trim()).filter(x => x)) : data.tags || []);
|
| 611 |
+
_.set(char, 'data.creator', data.creator || '');
|
| 612 |
+
_.set(char, 'data.character_version', data.character_version || '');
|
| 613 |
+
_.set(char, 'data.alternate_greetings', getAlternateGreetings(data));
|
| 614 |
+
|
| 615 |
+
// ST extension fields to V2 object
|
| 616 |
+
_.set(char, 'data.extensions.talkativeness', data.talkativeness || 0.5);
|
| 617 |
+
_.set(char, 'data.extensions.fav', data.fav == 'true');
|
| 618 |
+
_.set(char, 'data.extensions.world', data.world || '');
|
| 619 |
+
|
| 620 |
+
// Spec extension: depth prompt
|
| 621 |
+
const depth_default = 4;
|
| 622 |
+
const role_default = 'system';
|
| 623 |
+
const depth_value = !isNaN(Number(data.depth_prompt_depth)) ? Number(data.depth_prompt_depth) : depth_default;
|
| 624 |
+
const role_value = data.depth_prompt_role ?? role_default;
|
| 625 |
+
_.set(char, 'data.extensions.depth_prompt.prompt', data.depth_prompt_prompt ?? '');
|
| 626 |
+
_.set(char, 'data.extensions.depth_prompt.depth', depth_value);
|
| 627 |
+
_.set(char, 'data.extensions.depth_prompt.role', role_value);
|
| 628 |
+
|
| 629 |
+
if (data.world) {
|
| 630 |
+
try {
|
| 631 |
+
const file = readWorldInfoFile(directories, data.world, false);
|
| 632 |
+
|
| 633 |
+
// File was imported - save it to the character book
|
| 634 |
+
if (file && file.originalData) {
|
| 635 |
+
_.set(char, 'data.character_book', file.originalData);
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
// File was not imported - convert the world info to the character book
|
| 639 |
+
if (file && file.entries) {
|
| 640 |
+
_.set(char, 'data.character_book', convertWorldInfoToCharacterBook(data.world, file.entries));
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
} catch {
|
| 644 |
+
console.warn(`Failed to read world info file: ${data.world}. Character book will not be available.`);
|
| 645 |
+
}
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
if (data.extensions) {
|
| 649 |
+
try {
|
| 650 |
+
const extensions = JSON.parse(data.extensions);
|
| 651 |
+
// Deep merge the extensions object
|
| 652 |
+
_.set(char, 'data.extensions', deepMerge(char.data.extensions, extensions));
|
| 653 |
+
} catch {
|
| 654 |
+
console.warn(`Failed to parse extensions JSON: ${data.extensions}`);
|
| 655 |
+
}
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
return char;
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
/**
|
| 662 |
+
* @param {string} name Name of World Info file
|
| 663 |
+
* @param {object} entries Entries object
|
| 664 |
+
*/
|
| 665 |
+
function convertWorldInfoToCharacterBook(name, entries) {
|
| 666 |
+
/** @type {{ entries: object[]; name: string }} */
|
| 667 |
+
const result = { entries: [], name };
|
| 668 |
+
|
| 669 |
+
for (const index in entries) {
|
| 670 |
+
const entry = entries[index];
|
| 671 |
+
|
| 672 |
+
const originalEntry = {
|
| 673 |
+
id: entry.uid,
|
| 674 |
+
keys: entry.key,
|
| 675 |
+
secondary_keys: entry.keysecondary,
|
| 676 |
+
comment: entry.comment,
|
| 677 |
+
content: entry.content,
|
| 678 |
+
constant: entry.constant,
|
| 679 |
+
selective: entry.selective,
|
| 680 |
+
insertion_order: entry.order,
|
| 681 |
+
enabled: !entry.disable,
|
| 682 |
+
position: entry.position == 0 ? 'before_char' : 'after_char',
|
| 683 |
+
use_regex: true, // ST keys are always regex
|
| 684 |
+
extensions: {
|
| 685 |
+
...entry.extensions,
|
| 686 |
+
position: entry.position,
|
| 687 |
+
exclude_recursion: entry.excludeRecursion,
|
| 688 |
+
display_index: entry.displayIndex,
|
| 689 |
+
probability: entry.probability ?? null,
|
| 690 |
+
useProbability: entry.useProbability ?? false,
|
| 691 |
+
depth: entry.depth ?? 4,
|
| 692 |
+
selectiveLogic: entry.selectiveLogic ?? 0,
|
| 693 |
+
outlet_name: entry.outletName ?? '',
|
| 694 |
+
group: entry.group ?? '',
|
| 695 |
+
group_override: entry.groupOverride ?? false,
|
| 696 |
+
group_weight: entry.groupWeight ?? null,
|
| 697 |
+
prevent_recursion: entry.preventRecursion ?? false,
|
| 698 |
+
delay_until_recursion: entry.delayUntilRecursion ?? false,
|
| 699 |
+
scan_depth: entry.scanDepth ?? null,
|
| 700 |
+
match_whole_words: entry.matchWholeWords ?? null,
|
| 701 |
+
use_group_scoring: entry.useGroupScoring ?? false,
|
| 702 |
+
case_sensitive: entry.caseSensitive ?? null,
|
| 703 |
+
automation_id: entry.automationId ?? '',
|
| 704 |
+
role: entry.role ?? 0,
|
| 705 |
+
vectorized: entry.vectorized ?? false,
|
| 706 |
+
sticky: entry.sticky ?? null,
|
| 707 |
+
cooldown: entry.cooldown ?? null,
|
| 708 |
+
delay: entry.delay ?? null,
|
| 709 |
+
match_persona_description: entry.matchPersonaDescription ?? false,
|
| 710 |
+
match_character_description: entry.matchCharacterDescription ?? false,
|
| 711 |
+
match_character_personality: entry.matchCharacterPersonality ?? false,
|
| 712 |
+
match_character_depth_prompt: entry.matchCharacterDepthPrompt ?? false,
|
| 713 |
+
match_scenario: entry.matchScenario ?? false,
|
| 714 |
+
match_creator_notes: entry.matchCreatorNotes ?? false,
|
| 715 |
+
triggers: entry.triggers ?? [],
|
| 716 |
+
ignore_budget: entry.ignoreBudget ?? false,
|
| 717 |
+
},
|
| 718 |
+
};
|
| 719 |
+
|
| 720 |
+
result.entries.push(originalEntry);
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
return result;
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
/**
|
| 727 |
+
* Import a character from a YAML file.
|
| 728 |
+
* @param {string} uploadPath Path to the uploaded file
|
| 729 |
+
* @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects
|
| 730 |
+
* @param {string|undefined} preservedFileName Preserved file name
|
| 731 |
+
* @returns {Promise<string>} Internal name of the character
|
| 732 |
+
*/
|
| 733 |
+
async function importFromYaml(uploadPath, context, preservedFileName) {
|
| 734 |
+
const fileText = fs.readFileSync(uploadPath, 'utf8');
|
| 735 |
+
fs.unlinkSync(uploadPath);
|
| 736 |
+
const yamlData = yaml.parse(fileText);
|
| 737 |
+
console.info('Importing from YAML');
|
| 738 |
+
yamlData.name = sanitize(yamlData.name);
|
| 739 |
+
const fileName = preservedFileName || getPngName(yamlData.name, context.request.user.directories);
|
| 740 |
+
let char = convertToV2({
|
| 741 |
+
'name': yamlData.name,
|
| 742 |
+
'description': yamlData.context ?? '',
|
| 743 |
+
'first_mes': yamlData.greeting ?? '',
|
| 744 |
+
'create_date': new Date().toISOString(),
|
| 745 |
+
'chat': `${yamlData.name} - ${humanizedDateTime()}`,
|
| 746 |
+
'personality': '',
|
| 747 |
+
'creatorcomment': '',
|
| 748 |
+
'avatar': 'none',
|
| 749 |
+
'mes_example': '',
|
| 750 |
+
'scenario': '',
|
| 751 |
+
'talkativeness': 0.5,
|
| 752 |
+
'creator': '',
|
| 753 |
+
'tags': '',
|
| 754 |
+
}, context.request.user.directories);
|
| 755 |
+
const result = await writeCharacterData(DEFAULT_AVATAR_PATH, JSON.stringify(char), fileName, context.request);
|
| 756 |
+
return result ? fileName : '';
|
| 757 |
+
}
|
| 758 |
+
|
| 759 |
+
/**
|
| 760 |
+
* Imports a character card from CharX (ZIP) file.
|
| 761 |
+
* @param {string} uploadPath
|
| 762 |
+
* @param {object} params
|
| 763 |
+
* @param {import('express').Request} params.request
|
| 764 |
+
* @param {string|undefined} preservedFileName Preserved file name
|
| 765 |
+
* @returns {Promise<string>} Internal name of the character
|
| 766 |
+
*/
|
| 767 |
+
async function importFromCharX(uploadPath, { request }, preservedFileName) {
|
| 768 |
+
const fileBuffer = fs.readFileSync(uploadPath);
|
| 769 |
+
// Create a properly-sized ArrayBuffer (Node's buffer pool can cause oversized .buffer)
|
| 770 |
+
const data = fileBuffer.buffer.slice(fileBuffer.byteOffset, fileBuffer.byteOffset + fileBuffer.byteLength);
|
| 771 |
+
fs.unlinkSync(uploadPath);
|
| 772 |
+
|
| 773 |
+
const parser = new CharXParser(data);
|
| 774 |
+
const { card, avatar, auxiliaryAssets, extractedBuffers } = await parser.parse();
|
| 775 |
+
|
| 776 |
+
// Apply standard character transformations
|
| 777 |
+
let processedCard = readFromV2(card);
|
| 778 |
+
unsetPrivateFields(processedCard);
|
| 779 |
+
processedCard['create_date'] = new Date().toISOString();
|
| 780 |
+
processedCard.name = sanitize(processedCard.name);
|
| 781 |
+
|
| 782 |
+
const fileName = preservedFileName || getPngName(processedCard.name, request.user.directories);
|
| 783 |
+
// Use the actual character name for asset folders, not the unique filename
|
| 784 |
+
// ST's sprite system looks up by character name, not PNG filename
|
| 785 |
+
const characterFolder = processedCard.name;
|
| 786 |
+
|
| 787 |
+
if (auxiliaryAssets.length > 0) {
|
| 788 |
+
try {
|
| 789 |
+
const summary = persistCharXAssets(auxiliaryAssets, extractedBuffers, request.user.directories, characterFolder);
|
| 790 |
+
if (summary.sprites || summary.backgrounds || summary.misc) {
|
| 791 |
+
console.log(`CharX: Imported ${summary.sprites} sprite(s), ${summary.backgrounds} background(s), ${summary.misc} misc asset(s) for ${characterFolder}`);
|
| 792 |
+
}
|
| 793 |
+
} catch (error) {
|
| 794 |
+
console.warn(`CharX: Failed to persist auxiliary assets for ${characterFolder}`, error);
|
| 795 |
+
}
|
| 796 |
+
}
|
| 797 |
+
|
| 798 |
+
const result = await writeCharacterData(avatar, JSON.stringify(processedCard), fileName, request);
|
| 799 |
+
return result ? fileName : '';
|
| 800 |
+
}
|
| 801 |
+
|
| 802 |
+
async function importFromByaf(uploadPath, { request }, preservedFileName) {
|
| 803 |
+
const data = (await fsPromises.readFile(uploadPath)).buffer;
|
| 804 |
+
await fsPromises.unlink(uploadPath);
|
| 805 |
+
console.info('Importing from BYAF');
|
| 806 |
+
|
| 807 |
+
const byafData = await new ByafParser(data).parse();
|
| 808 |
+
const card = readFromV2(byafData.card);
|
| 809 |
+
const fileName = preservedFileName || getPngName(sanitize(byafData.character.displayName || card.name, { replacement: sanitizeSafeCharacterReplacements }), request.user.directories);
|
| 810 |
+
|
| 811 |
+
// Don't import chats and images if the character is being replaced or updated, instead of newly imported.
|
| 812 |
+
if (!preservedFileName) {
|
| 813 |
+
/**
|
| 814 |
+
* @param {Partial<ByafScenario>} scenario
|
| 815 |
+
*/
|
| 816 |
+
const createChatAsCurrentPersona = (scenario) => {
|
| 817 |
+
const chatName = sanitize(`${scenario.title || card.name} - ${humanizedDateTime()} imported.jsonl`, { replacement: sanitizeSafeCharacterReplacements });
|
| 818 |
+
const filePath = path.join(request.user.directories.chats, path.basename(fileName), chatName);
|
| 819 |
+
const dir = path.dirname(filePath);
|
| 820 |
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
| 821 |
+
writeFileAtomicSync(filePath, ByafParser.getChatFromScenario(scenario, request.body.user_name, card.name, byafData.chatBackgrounds), 'utf8');
|
| 822 |
+
console.log(`Created ${chatName} chat from BYAF import`);
|
| 823 |
+
return chatName;
|
| 824 |
+
};
|
| 825 |
+
|
| 826 |
+
// Upload backgrounds
|
| 827 |
+
for (const bg of byafData.chatBackgrounds) {
|
| 828 |
+
const extension = path.extname(bg.paths?.[0]) || '.png';
|
| 829 |
+
const baseName = `${path.basename(fileName)}_bg`;
|
| 830 |
+
const filePath = path.join(request.user.directories.userImages, fileName);
|
| 831 |
+
if (!fs.existsSync(filePath)) fs.mkdirSync(filePath, { recursive: true });
|
| 832 |
+
const file = getUniqueName(baseName, (name) => fs.existsSync(path.join(filePath, `${name}${extension}`)));
|
| 833 |
+
if (Buffer.isBuffer(bg.data)) {
|
| 834 |
+
const newFile = `${file}${extension}`;
|
| 835 |
+
writeFileAtomicSync(path.join(filePath, newFile), bg.data);
|
| 836 |
+
bg.name = clientRelativePath(request.user.directories.root, path.join(filePath, newFile)); // Update background name to the new file
|
| 837 |
+
console.log(`Created ${newFile} background from BYAF import`);
|
| 838 |
+
}
|
| 839 |
+
}
|
| 840 |
+
|
| 841 |
+
const chats = [];
|
| 842 |
+
// Create chats for each scenario
|
| 843 |
+
if (Array.isArray(byafData.scenarios)) {
|
| 844 |
+
for (const scenario of byafData.scenarios) {
|
| 845 |
+
chats.push(createChatAsCurrentPersona(scenario));
|
| 846 |
+
}
|
| 847 |
+
}
|
| 848 |
+
|
| 849 |
+
// Update the default chat if there are any so we open to an existing chat instead of creating a new one and opening that.
|
| 850 |
+
if (chats.length > 0) {
|
| 851 |
+
card.chat = path.basename(chats[0], path.extname(chats[0]));
|
| 852 |
+
}
|
| 853 |
+
|
| 854 |
+
// Save alternate icons for the character.
|
| 855 |
+
for (const icon of byafData.images.slice(1)) {
|
| 856 |
+
// BYAF does not support character expressions, so using the same structure will not result in conflicts,
|
| 857 |
+
// even if the expression system did not tolerate additional icons that are not mapped to expressions.
|
| 858 |
+
// This will not yet allow changing icons within the UI but at least the icons will be available for manual selection, rather than being lost.
|
| 859 |
+
const altImagesFolder = path.join(request.user.directories.characters, sanitize(card.name));
|
| 860 |
+
if (!fs.existsSync(altImagesFolder)) fs.mkdirSync(altImagesFolder, { recursive: true });
|
| 861 |
+
const extension = path.extname(icon.filename) || '.png';
|
| 862 |
+
const file = getUniqueName(`${sanitize(icon.label, { replacement: sanitizeSafeCharacterReplacements }) || 'alt'}`, (name) => fs.existsSync(path.join(altImagesFolder, `${name}${extension}`)));
|
| 863 |
+
if (Buffer.isBuffer(icon.image)) {
|
| 864 |
+
writeFileAtomicSync(path.join(altImagesFolder, `${file}${extension}`), icon.image);
|
| 865 |
+
console.log(`Created ${file}${extension} alternate icon from BYAF import`);
|
| 866 |
+
}
|
| 867 |
+
}
|
| 868 |
+
}
|
| 869 |
+
|
| 870 |
+
const result = await writeCharacterData(byafData.images[0].image, JSON.stringify(card), fileName, request);
|
| 871 |
+
|
| 872 |
+
return result ? fileName : '';
|
| 873 |
+
}
|
| 874 |
+
|
| 875 |
+
/**
|
| 876 |
+
* Import a character from a JSON file.
|
| 877 |
+
* @param {string} uploadPath Path to the uploaded file
|
| 878 |
+
* @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects
|
| 879 |
+
* @param {string|undefined} preservedFileName Preserved file name
|
| 880 |
+
* @returns {Promise<string>} Internal name of the character
|
| 881 |
+
*/
|
| 882 |
+
async function importFromJson(uploadPath, { request }, preservedFileName) {
|
| 883 |
+
const data = fs.readFileSync(uploadPath, 'utf8');
|
| 884 |
+
fs.unlinkSync(uploadPath);
|
| 885 |
+
|
| 886 |
+
let jsonData = JSON.parse(data);
|
| 887 |
+
|
| 888 |
+
if (jsonData.spec !== undefined) {
|
| 889 |
+
console.info(`Importing from ${jsonData.spec} json`);
|
| 890 |
+
importRisuSprites(request.user.directories, jsonData);
|
| 891 |
+
unsetPrivateFields(jsonData);
|
| 892 |
+
jsonData = readFromV2(jsonData);
|
| 893 |
+
jsonData['create_date'] = new Date().toISOString();
|
| 894 |
+
const pngName = preservedFileName || getPngName(jsonData.data?.name || jsonData.name, request.user.directories);
|
| 895 |
+
const char = JSON.stringify(jsonData);
|
| 896 |
+
const result = await writeCharacterData(DEFAULT_AVATAR_PATH, char, pngName, request);
|
| 897 |
+
return result ? pngName : '';
|
| 898 |
+
} else if (jsonData.name !== undefined) {
|
| 899 |
+
console.info('Importing from v1 json');
|
| 900 |
+
jsonData.name = sanitize(jsonData.name);
|
| 901 |
+
if (jsonData.creator_notes) {
|
| 902 |
+
jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', '');
|
| 903 |
+
}
|
| 904 |
+
const pngName = preservedFileName || getPngName(jsonData.name, request.user.directories);
|
| 905 |
+
let char = {
|
| 906 |
+
'name': jsonData.name,
|
| 907 |
+
'description': jsonData.description ?? '',
|
| 908 |
+
'creatorcomment': jsonData.creatorcomment ?? jsonData.creator_notes ?? '',
|
| 909 |
+
'personality': jsonData.personality ?? '',
|
| 910 |
+
'first_mes': jsonData.first_mes ?? '',
|
| 911 |
+
'avatar': 'none',
|
| 912 |
+
'chat': jsonData.name + ' - ' + humanizedDateTime(),
|
| 913 |
+
'mes_example': jsonData.mes_example ?? '',
|
| 914 |
+
'scenario': jsonData.scenario ?? '',
|
| 915 |
+
'create_date': new Date().toISOString(),
|
| 916 |
+
'talkativeness': jsonData.talkativeness ?? 0.5,
|
| 917 |
+
'creator': jsonData.creator ?? '',
|
| 918 |
+
'tags': jsonData.tags ?? '',
|
| 919 |
+
};
|
| 920 |
+
char = convertToV2(char, request.user.directories);
|
| 921 |
+
let charJSON = JSON.stringify(char);
|
| 922 |
+
const result = await writeCharacterData(DEFAULT_AVATAR_PATH, charJSON, pngName, request);
|
| 923 |
+
return result ? pngName : '';
|
| 924 |
+
} else if (jsonData.char_name !== undefined) {//json Pygmalion notepad
|
| 925 |
+
console.info('Importing from gradio json');
|
| 926 |
+
jsonData.char_name = sanitize(jsonData.char_name);
|
| 927 |
+
if (jsonData.creator_notes) {
|
| 928 |
+
jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', '');
|
| 929 |
+
}
|
| 930 |
+
const pngName = preservedFileName || getPngName(jsonData.char_name, request.user.directories);
|
| 931 |
+
let char = {
|
| 932 |
+
'name': jsonData.char_name,
|
| 933 |
+
'description': jsonData.char_persona ?? '',
|
| 934 |
+
'creatorcomment': jsonData.creatorcomment ?? jsonData.creator_notes ?? '',
|
| 935 |
+
'personality': '',
|
| 936 |
+
'first_mes': jsonData.char_greeting ?? '',
|
| 937 |
+
'avatar': 'none',
|
| 938 |
+
'chat': jsonData.name + ' - ' + humanizedDateTime(),
|
| 939 |
+
'mes_example': jsonData.example_dialogue ?? '',
|
| 940 |
+
'scenario': jsonData.world_scenario ?? '',
|
| 941 |
+
'create_date': new Date().toISOString(),
|
| 942 |
+
'talkativeness': jsonData.talkativeness ?? 0.5,
|
| 943 |
+
'creator': jsonData.creator ?? '',
|
| 944 |
+
'tags': jsonData.tags ?? '',
|
| 945 |
+
};
|
| 946 |
+
char = convertToV2(char, request.user.directories);
|
| 947 |
+
const charJSON = JSON.stringify(char);
|
| 948 |
+
const result = await writeCharacterData(DEFAULT_AVATAR_PATH, charJSON, pngName, request);
|
| 949 |
+
return result ? pngName : '';
|
| 950 |
+
}
|
| 951 |
+
|
| 952 |
+
return '';
|
| 953 |
+
}
|
| 954 |
+
|
| 955 |
+
/**
|
| 956 |
+
* Import a character from a PNG file.
|
| 957 |
+
* @param {string} uploadPath Path to the uploaded file
|
| 958 |
+
* @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects
|
| 959 |
+
* @param {string|undefined} preservedFileName Preserved file name
|
| 960 |
+
* @returns {Promise<string>} Internal name of the character
|
| 961 |
+
*/
|
| 962 |
+
async function importFromPng(uploadPath, { request }, preservedFileName) {
|
| 963 |
+
const imgData = await readCharacterData(uploadPath);
|
| 964 |
+
if (imgData === undefined) throw new Error('Failed to read character data');
|
| 965 |
+
|
| 966 |
+
let jsonData = JSON.parse(imgData);
|
| 967 |
+
|
| 968 |
+
jsonData.name = sanitize(jsonData.data?.name || jsonData.name);
|
| 969 |
+
const pngName = preservedFileName || getPngName(jsonData.name, request.user.directories);
|
| 970 |
+
|
| 971 |
+
if (jsonData.spec !== undefined) {
|
| 972 |
+
console.info(`Found a ${jsonData.spec} character file.`);
|
| 973 |
+
importRisuSprites(request.user.directories, jsonData);
|
| 974 |
+
unsetPrivateFields(jsonData);
|
| 975 |
+
jsonData = readFromV2(jsonData);
|
| 976 |
+
jsonData['create_date'] = new Date().toISOString();
|
| 977 |
+
const char = JSON.stringify(jsonData);
|
| 978 |
+
const result = await writeCharacterData(uploadPath, char, pngName, request);
|
| 979 |
+
fs.unlinkSync(uploadPath);
|
| 980 |
+
return result ? pngName : '';
|
| 981 |
+
} else if (jsonData.name !== undefined) {
|
| 982 |
+
console.info('Found a v1 character file.');
|
| 983 |
+
|
| 984 |
+
if (jsonData.creator_notes) {
|
| 985 |
+
jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', '');
|
| 986 |
+
}
|
| 987 |
+
|
| 988 |
+
let char = {
|
| 989 |
+
'name': jsonData.name,
|
| 990 |
+
'description': jsonData.description ?? '',
|
| 991 |
+
'creatorcomment': jsonData.creatorcomment ?? jsonData.creator_notes ?? '',
|
| 992 |
+
'personality': jsonData.personality ?? '',
|
| 993 |
+
'first_mes': jsonData.first_mes ?? '',
|
| 994 |
+
'avatar': 'none',
|
| 995 |
+
'chat': jsonData.name + ' - ' + humanizedDateTime(),
|
| 996 |
+
'mes_example': jsonData.mes_example ?? '',
|
| 997 |
+
'scenario': jsonData.scenario ?? '',
|
| 998 |
+
'create_date': new Date().toISOString(),
|
| 999 |
+
'talkativeness': jsonData.talkativeness ?? 0.5,
|
| 1000 |
+
'creator': jsonData.creator ?? '',
|
| 1001 |
+
'tags': jsonData.tags ?? '',
|
| 1002 |
+
};
|
| 1003 |
+
char = convertToV2(char, request.user.directories);
|
| 1004 |
+
const charJSON = JSON.stringify(char);
|
| 1005 |
+
const result = await writeCharacterData(uploadPath, charJSON, pngName, request);
|
| 1006 |
+
fs.unlinkSync(uploadPath);
|
| 1007 |
+
return result ? pngName : '';
|
| 1008 |
+
}
|
| 1009 |
+
|
| 1010 |
+
return '';
|
| 1011 |
+
}
|
| 1012 |
+
|
| 1013 |
+
export const router = express.Router();
|
| 1014 |
+
|
| 1015 |
+
router.post('/create', getFileNameValidationFunction('file_name'), async function (request, response) {
|
| 1016 |
+
try {
|
| 1017 |
+
if (!request.body) return response.sendStatus(400);
|
| 1018 |
+
|
| 1019 |
+
request.body.ch_name = sanitize(request.body.ch_name);
|
| 1020 |
+
|
| 1021 |
+
const char = JSON.stringify(charaFormatData(request.body, request.user.directories));
|
| 1022 |
+
const internalName = request.body.file_name || getPngName(request.body.ch_name, request.user.directories);
|
| 1023 |
+
const avatarName = `${internalName}.png`;
|
| 1024 |
+
const chatsPath = path.join(request.user.directories.chats, internalName);
|
| 1025 |
+
|
| 1026 |
+
if (!fs.existsSync(chatsPath)) fs.mkdirSync(chatsPath);
|
| 1027 |
+
|
| 1028 |
+
if (!request.file) {
|
| 1029 |
+
await writeCharacterData(DEFAULT_AVATAR_PATH, char, internalName, request);
|
| 1030 |
+
return response.send(avatarName);
|
| 1031 |
+
} else {
|
| 1032 |
+
const crop = tryParse(request.query.crop);
|
| 1033 |
+
const uploadPath = path.join(request.file.destination, request.file.filename);
|
| 1034 |
+
await writeCharacterData(uploadPath, char, internalName, request, crop);
|
| 1035 |
+
fs.unlinkSync(uploadPath);
|
| 1036 |
+
return response.send(avatarName);
|
| 1037 |
+
}
|
| 1038 |
+
} catch (err) {
|
| 1039 |
+
console.error(err);
|
| 1040 |
+
response.sendStatus(500);
|
| 1041 |
+
}
|
| 1042 |
+
});
|
| 1043 |
+
|
| 1044 |
+
router.post('/rename', validateAvatarUrlMiddleware, async function (request, response) {
|
| 1045 |
+
if (!request.body.avatar_url || !request.body.new_name) {
|
| 1046 |
+
return response.sendStatus(400);
|
| 1047 |
+
}
|
| 1048 |
+
|
| 1049 |
+
const oldAvatarName = request.body.avatar_url;
|
| 1050 |
+
const newName = sanitize(request.body.new_name);
|
| 1051 |
+
const oldInternalName = path.parse(request.body.avatar_url).name;
|
| 1052 |
+
const newInternalName = getPngName(newName, request.user.directories);
|
| 1053 |
+
const newAvatarName = `${newInternalName}.png`;
|
| 1054 |
+
|
| 1055 |
+
const oldAvatarPath = path.join(request.user.directories.characters, oldAvatarName);
|
| 1056 |
+
|
| 1057 |
+
const oldChatsPath = path.join(request.user.directories.chats, oldInternalName);
|
| 1058 |
+
const newChatsPath = path.join(request.user.directories.chats, newInternalName);
|
| 1059 |
+
|
| 1060 |
+
try {
|
| 1061 |
+
// Read old file, replace name int it
|
| 1062 |
+
const rawOldData = await readCharacterData(oldAvatarPath);
|
| 1063 |
+
if (rawOldData === undefined) throw new Error('Failed to read character file');
|
| 1064 |
+
|
| 1065 |
+
const oldData = getCharaCardV2(JSON.parse(rawOldData), request.user.directories);
|
| 1066 |
+
_.set(oldData, 'data.name', newName);
|
| 1067 |
+
_.set(oldData, 'name', newName);
|
| 1068 |
+
const newData = JSON.stringify(oldData);
|
| 1069 |
+
|
| 1070 |
+
// Write data to new location
|
| 1071 |
+
await writeCharacterData(oldAvatarPath, newData, newInternalName, request);
|
| 1072 |
+
|
| 1073 |
+
// Rename chats folder
|
| 1074 |
+
if (fs.existsSync(oldChatsPath) && !fs.existsSync(newChatsPath)) {
|
| 1075 |
+
fs.cpSync(oldChatsPath, newChatsPath, { recursive: true });
|
| 1076 |
+
fs.rmSync(oldChatsPath, { recursive: true, force: true });
|
| 1077 |
+
}
|
| 1078 |
+
|
| 1079 |
+
// Remove the old character file
|
| 1080 |
+
fs.unlinkSync(oldAvatarPath);
|
| 1081 |
+
|
| 1082 |
+
// Return new avatar name to ST
|
| 1083 |
+
return response.send({ avatar: newAvatarName });
|
| 1084 |
+
}
|
| 1085 |
+
catch (err) {
|
| 1086 |
+
console.error(err);
|
| 1087 |
+
return response.sendStatus(500);
|
| 1088 |
+
}
|
| 1089 |
+
});
|
| 1090 |
+
|
| 1091 |
+
router.post('/edit', validateAvatarUrlMiddleware, async function (request, response) {
|
| 1092 |
+
if (!request.body) {
|
| 1093 |
+
console.warn('Error: no response body detected');
|
| 1094 |
+
response.status(400).send('Error: no response body detected');
|
| 1095 |
+
return;
|
| 1096 |
+
}
|
| 1097 |
+
|
| 1098 |
+
if (request.body.ch_name === '' || request.body.ch_name === undefined || request.body.ch_name === '.') {
|
| 1099 |
+
console.warn('Error: invalid name.');
|
| 1100 |
+
response.status(400).send('Error: invalid name.');
|
| 1101 |
+
return;
|
| 1102 |
+
}
|
| 1103 |
+
|
| 1104 |
+
let char = charaFormatData(request.body, request.user.directories);
|
| 1105 |
+
char.chat = request.body.chat;
|
| 1106 |
+
char.create_date = request.body.create_date;
|
| 1107 |
+
char = JSON.stringify(char);
|
| 1108 |
+
let targetFile = (request.body.avatar_url).replace('.png', '');
|
| 1109 |
+
|
| 1110 |
+
try {
|
| 1111 |
+
if (!request.file) {
|
| 1112 |
+
const avatarPath = path.join(request.user.directories.characters, request.body.avatar_url);
|
| 1113 |
+
await writeCharacterData(avatarPath, char, targetFile, request);
|
| 1114 |
+
} else {
|
| 1115 |
+
const crop = tryParse(request.query.crop);
|
| 1116 |
+
const newAvatarPath = path.join(request.file.destination, request.file.filename);
|
| 1117 |
+
invalidateThumbnail(request.user.directories, 'avatar', request.body.avatar_url);
|
| 1118 |
+
await writeCharacterData(newAvatarPath, char, targetFile, request, crop);
|
| 1119 |
+
fs.unlinkSync(newAvatarPath);
|
| 1120 |
+
|
| 1121 |
+
// Bust cache to reload the new avatar
|
| 1122 |
+
cacheBuster.bust(request, response);
|
| 1123 |
+
}
|
| 1124 |
+
|
| 1125 |
+
return response.sendStatus(200);
|
| 1126 |
+
} catch (err) {
|
| 1127 |
+
console.error('An error occurred, character edit invalidated.', err);
|
| 1128 |
+
return response.sendStatus(500);
|
| 1129 |
+
}
|
| 1130 |
+
});
|
| 1131 |
+
|
| 1132 |
+
router.post('/edit-avatar', validateAvatarUrlMiddleware, async function (request, response) {
|
| 1133 |
+
try {
|
| 1134 |
+
if (!request.file) {
|
| 1135 |
+
return response.status(400).send('Error: no file uploaded');
|
| 1136 |
+
}
|
| 1137 |
+
|
| 1138 |
+
if (!request.body || !request.body.avatar_url) {
|
| 1139 |
+
return response.status(400).send('Error: no avatar_url in request body');
|
| 1140 |
+
}
|
| 1141 |
+
|
| 1142 |
+
const uploadPath = path.join(request.file.destination, request.file.filename);
|
| 1143 |
+
if (!fs.existsSync(uploadPath)) {
|
| 1144 |
+
return response.status(400).send('Error: uploaded file does not exist');
|
| 1145 |
+
}
|
| 1146 |
+
const characterPath = path.join(request.user.directories.characters, request.body.avatar_url);
|
| 1147 |
+
if (!fs.existsSync(characterPath)) {
|
| 1148 |
+
return response.status(400).send('Error: character file does not exist');
|
| 1149 |
+
}
|
| 1150 |
+
const data = await readCharacterData(characterPath);
|
| 1151 |
+
if (!data) {
|
| 1152 |
+
return response.status(400).send('Error: failed to read character data');
|
| 1153 |
+
}
|
| 1154 |
+
|
| 1155 |
+
const crop = tryParse(request.query.crop);
|
| 1156 |
+
const fileName = request.body.avatar_url.replace('.png', '');
|
| 1157 |
+
await writeCharacterData(uploadPath, data, fileName, request, crop);
|
| 1158 |
+
|
| 1159 |
+
// Remove uploaded temp file
|
| 1160 |
+
fs.unlinkSync(uploadPath);
|
| 1161 |
+
|
| 1162 |
+
// Reset images caches
|
| 1163 |
+
cacheBuster.bust(request, response);
|
| 1164 |
+
invalidateThumbnail(request.user.directories, 'avatar', request.body.avatar_url);
|
| 1165 |
+
|
| 1166 |
+
return response.sendStatus(200);
|
| 1167 |
+
} catch (err) {
|
| 1168 |
+
console.error('An error occurred while editing avatar', err);
|
| 1169 |
+
return response.sendStatus(500);
|
| 1170 |
+
}
|
| 1171 |
+
});
|
| 1172 |
+
|
| 1173 |
+
/**
|
| 1174 |
+
* Handle a POST request to edit a character attribute.
|
| 1175 |
+
*
|
| 1176 |
+
* This function reads the character data from a file, updates the specified attribute,
|
| 1177 |
+
* and writes the updated data back to the file.
|
| 1178 |
+
*
|
| 1179 |
+
* @param {Object} request - The HTTP request object.
|
| 1180 |
+
* @param {Object} response - The HTTP response object.
|
| 1181 |
+
* @returns {void}
|
| 1182 |
+
*/
|
| 1183 |
+
router.post('/edit-attribute', validateAvatarUrlMiddleware, async function (request, response) {
|
| 1184 |
+
console.debug(request.body);
|
| 1185 |
+
if (!request.body) {
|
| 1186 |
+
console.warn('Error: no response body detected');
|
| 1187 |
+
return response.status(400).send('Error: no response body detected');
|
| 1188 |
+
}
|
| 1189 |
+
|
| 1190 |
+
if (request.body.ch_name === '' || request.body.ch_name === undefined || request.body.ch_name === '.') {
|
| 1191 |
+
console.warn('Error: invalid name.');
|
| 1192 |
+
return response.status(400).send('Error: invalid name.');
|
| 1193 |
+
}
|
| 1194 |
+
|
| 1195 |
+
if (request.body.field === 'json_data') {
|
| 1196 |
+
console.warn('Error: cannot edit json_data field.');
|
| 1197 |
+
return response.status(400).send('Error: cannot edit json_data field.');
|
| 1198 |
+
}
|
| 1199 |
+
|
| 1200 |
+
try {
|
| 1201 |
+
const avatarPath = path.join(request.user.directories.characters, request.body.avatar_url);
|
| 1202 |
+
const charJSON = await readCharacterData(avatarPath);
|
| 1203 |
+
if (typeof charJSON !== 'string') throw new Error('Failed to read character file');
|
| 1204 |
+
|
| 1205 |
+
const char = JSON.parse(charJSON);
|
| 1206 |
+
//check if the field exists
|
| 1207 |
+
if (char[request.body.field] === undefined && char.data[request.body.field] === undefined) {
|
| 1208 |
+
console.warn('Error: invalid field.');
|
| 1209 |
+
response.status(400).send('Error: invalid field.');
|
| 1210 |
+
return;
|
| 1211 |
+
}
|
| 1212 |
+
char[request.body.field] = request.body.value;
|
| 1213 |
+
char.data[request.body.field] = request.body.value;
|
| 1214 |
+
let newCharJSON = JSON.stringify(char);
|
| 1215 |
+
const targetFile = (request.body.avatar_url).replace('.png', '');
|
| 1216 |
+
await writeCharacterData(avatarPath, newCharJSON, targetFile, request);
|
| 1217 |
+
return response.sendStatus(200);
|
| 1218 |
+
} catch (err) {
|
| 1219 |
+
console.error('An error occurred, character edit invalidated.', err);
|
| 1220 |
+
return response.sendStatus(500);
|
| 1221 |
+
}
|
| 1222 |
+
});
|
| 1223 |
+
|
| 1224 |
+
/**
|
| 1225 |
+
* Handle a POST request to edit character properties.
|
| 1226 |
+
*
|
| 1227 |
+
* Merges the request body with the selected character and
|
| 1228 |
+
* validates the result against TavernCard V2 specification.
|
| 1229 |
+
*
|
| 1230 |
+
* @param {Object} request - The HTTP request object.
|
| 1231 |
+
* @param {Object} response - The HTTP response object.
|
| 1232 |
+
*
|
| 1233 |
+
* @returns {void}
|
| 1234 |
+
* */
|
| 1235 |
+
router.post('/merge-attributes', getFileNameValidationFunction('avatar'), async function (request, response) {
|
| 1236 |
+
try {
|
| 1237 |
+
const update = request.body;
|
| 1238 |
+
const avatarPath = path.join(request.user.directories.characters, update.avatar);
|
| 1239 |
+
|
| 1240 |
+
const pngStringData = await readCharacterData(avatarPath);
|
| 1241 |
+
|
| 1242 |
+
if (!pngStringData) {
|
| 1243 |
+
console.error('Error: invalid character file.');
|
| 1244 |
+
return response.status(400).send('Error: invalid character file.');
|
| 1245 |
+
}
|
| 1246 |
+
|
| 1247 |
+
let character = JSON.parse(pngStringData);
|
| 1248 |
+
|
| 1249 |
+
_.unset(update, 'json_data');
|
| 1250 |
+
_.unset(character, 'json_data');
|
| 1251 |
+
|
| 1252 |
+
character = deepMerge(character, update);
|
| 1253 |
+
|
| 1254 |
+
const validator = new TavernCardValidator(character);
|
| 1255 |
+
const targetImg = (update.avatar).replace('.png', '');
|
| 1256 |
+
|
| 1257 |
+
//Accept either V1 or V2.
|
| 1258 |
+
if (validator.validate()) {
|
| 1259 |
+
await writeCharacterData(avatarPath, JSON.stringify(character), targetImg, request);
|
| 1260 |
+
response.sendStatus(200);
|
| 1261 |
+
} else {
|
| 1262 |
+
console.warn(validator.lastValidationError);
|
| 1263 |
+
response.status(400).send({ message: `Validation failed for ${character.name}`, error: validator.lastValidationError });
|
| 1264 |
+
}
|
| 1265 |
+
} catch (exception) {
|
| 1266 |
+
response.status(500).send({ message: 'Unexpected error while saving character.', error: exception.toString() });
|
| 1267 |
+
}
|
| 1268 |
+
});
|
| 1269 |
+
|
| 1270 |
+
router.post('/delete', validateAvatarUrlMiddleware, async function (request, response) {
|
| 1271 |
+
if (!request.body || !request.body.avatar_url) {
|
| 1272 |
+
return response.sendStatus(400);
|
| 1273 |
+
}
|
| 1274 |
+
|
| 1275 |
+
if (request.body.avatar_url !== sanitize(request.body.avatar_url)) {
|
| 1276 |
+
console.error('Malicious filename prevented');
|
| 1277 |
+
return response.sendStatus(403);
|
| 1278 |
+
}
|
| 1279 |
+
|
| 1280 |
+
const avatarPath = path.join(request.user.directories.characters, request.body.avatar_url);
|
| 1281 |
+
if (!fs.existsSync(avatarPath)) {
|
| 1282 |
+
return response.sendStatus(400);
|
| 1283 |
+
}
|
| 1284 |
+
|
| 1285 |
+
fs.unlinkSync(avatarPath);
|
| 1286 |
+
invalidateThumbnail(request.user.directories, 'avatar', request.body.avatar_url);
|
| 1287 |
+
let dir_name = (request.body.avatar_url.replace('.png', ''));
|
| 1288 |
+
|
| 1289 |
+
if (!dir_name.length) {
|
| 1290 |
+
console.error('Malicious dirname prevented');
|
| 1291 |
+
return response.sendStatus(403);
|
| 1292 |
+
}
|
| 1293 |
+
|
| 1294 |
+
if (request.body.delete_chats == true) {
|
| 1295 |
+
try {
|
| 1296 |
+
await fs.promises.rm(path.join(request.user.directories.chats, sanitize(dir_name)), { recursive: true, force: true });
|
| 1297 |
+
} catch (err) {
|
| 1298 |
+
console.error(err);
|
| 1299 |
+
return response.sendStatus(500);
|
| 1300 |
+
}
|
| 1301 |
+
}
|
| 1302 |
+
|
| 1303 |
+
return response.sendStatus(200);
|
| 1304 |
+
});
|
| 1305 |
+
|
| 1306 |
+
/**
|
| 1307 |
+
* HTTP POST endpoint for the "/api/characters/all" route.
|
| 1308 |
+
*
|
| 1309 |
+
* This endpoint is responsible for reading character files from the `charactersPath` directory,
|
| 1310 |
+
* parsing character data, calculating stats for each character and responding with the data.
|
| 1311 |
+
* Stats are calculated only on the first run, on subsequent runs the stats are fetched from
|
| 1312 |
+
* the `charStats` variable.
|
| 1313 |
+
* The stats are calculated by the `calculateStats` function.
|
| 1314 |
+
* The characters are processed by the `processCharacter` function.
|
| 1315 |
+
*
|
| 1316 |
+
* @param {import("express").Request} request The HTTP request object.
|
| 1317 |
+
* @param {import("express").Response} response The HTTP response object.
|
| 1318 |
+
* @return {void}
|
| 1319 |
+
*/
|
| 1320 |
+
router.post('/all', async function (request, response) {
|
| 1321 |
+
try {
|
| 1322 |
+
const files = fs.readdirSync(request.user.directories.characters);
|
| 1323 |
+
const pngFiles = files.filter(file => file.endsWith('.png'));
|
| 1324 |
+
const processingPromises = pngFiles.map(file => processCharacter(file, request.user.directories, { shallow: useShallowCharacters }));
|
| 1325 |
+
const data = (await Promise.all(processingPromises)).filter(c => c.name);
|
| 1326 |
+
return response.send(data);
|
| 1327 |
+
} catch (err) {
|
| 1328 |
+
console.error(err);
|
| 1329 |
+
const isRangeError = err instanceof RangeError;
|
| 1330 |
+
response.status(500).send({ overflow: isRangeError, error: true });
|
| 1331 |
+
}
|
| 1332 |
+
});
|
| 1333 |
+
|
| 1334 |
+
router.post('/get', validateAvatarUrlMiddleware, async function (request, response) {
|
| 1335 |
+
try {
|
| 1336 |
+
if (!request.body) return response.sendStatus(400);
|
| 1337 |
+
const item = request.body.avatar_url;
|
| 1338 |
+
const filePath = path.join(request.user.directories.characters, item);
|
| 1339 |
+
|
| 1340 |
+
if (!fs.existsSync(filePath)) {
|
| 1341 |
+
return response.sendStatus(404);
|
| 1342 |
+
}
|
| 1343 |
+
|
| 1344 |
+
const data = await processCharacter(item, request.user.directories, { shallow: false });
|
| 1345 |
+
|
| 1346 |
+
return response.send(data);
|
| 1347 |
+
} catch (err) {
|
| 1348 |
+
console.error(err);
|
| 1349 |
+
response.sendStatus(500);
|
| 1350 |
+
}
|
| 1351 |
+
});
|
| 1352 |
+
|
| 1353 |
+
router.post('/chats', validateAvatarUrlMiddleware, async function (request, response) {
|
| 1354 |
+
try {
|
| 1355 |
+
if (!request.body) return response.sendStatus(400);
|
| 1356 |
+
|
| 1357 |
+
const characterDirectory = (request.body.avatar_url).replace('.png', '');
|
| 1358 |
+
const chatsDirectory = path.join(request.user.directories.chats, characterDirectory);
|
| 1359 |
+
|
| 1360 |
+
if (!fs.existsSync(chatsDirectory)) {
|
| 1361 |
+
return response.send({ error: true });
|
| 1362 |
+
}
|
| 1363 |
+
|
| 1364 |
+
const files = fs.readdirSync(chatsDirectory, { withFileTypes: true });
|
| 1365 |
+
const jsonFiles = files.filter(file => file.isFile() && path.extname(file.name) === '.jsonl').map(file => file.name);
|
| 1366 |
+
|
| 1367 |
+
if (jsonFiles.length === 0) {
|
| 1368 |
+
return response.send([]);
|
| 1369 |
+
}
|
| 1370 |
+
|
| 1371 |
+
if (request.body.simple) {
|
| 1372 |
+
return response.send(jsonFiles.map(file => ({ file_name: file, file_id: path.parse(file).name })));
|
| 1373 |
+
}
|
| 1374 |
+
|
| 1375 |
+
const jsonFilesPromise = jsonFiles.map((file) => {
|
| 1376 |
+
const withMetadata = !!request.body.metadata;
|
| 1377 |
+
const pathToFile = path.join(request.user.directories.chats, characterDirectory, file);
|
| 1378 |
+
return getChatInfo(pathToFile, {}, withMetadata);
|
| 1379 |
+
});
|
| 1380 |
+
|
| 1381 |
+
const chatData = (await Promise.allSettled(jsonFilesPromise)).filter(x => x.status === 'fulfilled').map(x => x.value);
|
| 1382 |
+
const validFiles = chatData.filter(i => i.file_name);
|
| 1383 |
+
|
| 1384 |
+
return response.send(validFiles);
|
| 1385 |
+
} catch (error) {
|
| 1386 |
+
console.error(error);
|
| 1387 |
+
return response.send({ error: true });
|
| 1388 |
+
}
|
| 1389 |
+
});
|
| 1390 |
+
|
| 1391 |
+
/**
|
| 1392 |
+
* Gets the name for the uploaded PNG file.
|
| 1393 |
+
* @param {string} file File name
|
| 1394 |
+
* @param {import('../users.js').UserDirectoryList} directories User directories
|
| 1395 |
+
* @returns {string} - The name for the uploaded PNG file
|
| 1396 |
+
*/
|
| 1397 |
+
function getPngName(file, directories) {
|
| 1398 |
+
let i = 1;
|
| 1399 |
+
const baseName = file;
|
| 1400 |
+
while (fs.existsSync(path.join(directories.characters, `${file}.png`))) {
|
| 1401 |
+
file = baseName + i;
|
| 1402 |
+
i++;
|
| 1403 |
+
}
|
| 1404 |
+
return file;
|
| 1405 |
+
}
|
| 1406 |
+
|
| 1407 |
+
/**
|
| 1408 |
+
* Gets the preserved name for the uploaded file if the request is valid.
|
| 1409 |
+
* @param {import("express").Request} request - Express request object
|
| 1410 |
+
* @returns {string | undefined} - The preserved name if the request is valid, otherwise undefined
|
| 1411 |
+
*/
|
| 1412 |
+
function getPreservedName(request) {
|
| 1413 |
+
return typeof request.body.preserved_name === 'string' && request.body.preserved_name.length > 0
|
| 1414 |
+
? path.parse(request.body.preserved_name).name
|
| 1415 |
+
: undefined;
|
| 1416 |
+
}
|
| 1417 |
+
|
| 1418 |
+
router.post('/import', async function (request, response) {
|
| 1419 |
+
if (!request.body || !request.file) return response.sendStatus(400);
|
| 1420 |
+
|
| 1421 |
+
const uploadPath = path.join(request.file.destination, request.file.filename);
|
| 1422 |
+
const format = request.body.file_type;
|
| 1423 |
+
const preservedFileName = getPreservedName(request);
|
| 1424 |
+
|
| 1425 |
+
const formatImportFunctions = {
|
| 1426 |
+
'yaml': importFromYaml,
|
| 1427 |
+
'yml': importFromYaml,
|
| 1428 |
+
'json': importFromJson,
|
| 1429 |
+
'png': importFromPng,
|
| 1430 |
+
'charx': importFromCharX,
|
| 1431 |
+
'byaf': importFromByaf,
|
| 1432 |
+
};
|
| 1433 |
+
|
| 1434 |
+
try {
|
| 1435 |
+
const importFunction = formatImportFunctions[format];
|
| 1436 |
+
|
| 1437 |
+
if (!importFunction) {
|
| 1438 |
+
throw new Error(`Unsupported format: ${format}`);
|
| 1439 |
+
}
|
| 1440 |
+
|
| 1441 |
+
const fileName = await importFunction(uploadPath, { request, response }, preservedFileName);
|
| 1442 |
+
|
| 1443 |
+
if (!fileName) {
|
| 1444 |
+
console.warn('Failed to import character');
|
| 1445 |
+
return response.sendStatus(400);
|
| 1446 |
+
}
|
| 1447 |
+
|
| 1448 |
+
if (preservedFileName) {
|
| 1449 |
+
invalidateThumbnail(request.user.directories, 'avatar', `${preservedFileName}.png`);
|
| 1450 |
+
}
|
| 1451 |
+
|
| 1452 |
+
response.send({ file_name: fileName });
|
| 1453 |
+
} catch (err) {
|
| 1454 |
+
console.error(err);
|
| 1455 |
+
response.send({ error: true });
|
| 1456 |
+
}
|
| 1457 |
+
});
|
| 1458 |
+
|
| 1459 |
+
router.post('/duplicate', validateAvatarUrlMiddleware, async function (request, response) {
|
| 1460 |
+
try {
|
| 1461 |
+
if (!request.body.avatar_url) {
|
| 1462 |
+
console.warn('avatar URL not found in request body');
|
| 1463 |
+
console.debug(request.body);
|
| 1464 |
+
return response.sendStatus(400);
|
| 1465 |
+
}
|
| 1466 |
+
let filename = path.join(request.user.directories.characters, sanitize(request.body.avatar_url));
|
| 1467 |
+
if (!fs.existsSync(filename)) {
|
| 1468 |
+
console.error('file for dupe not found', filename);
|
| 1469 |
+
return response.sendStatus(404);
|
| 1470 |
+
}
|
| 1471 |
+
let suffix = 1;
|
| 1472 |
+
let newFilename = filename;
|
| 1473 |
+
|
| 1474 |
+
// If filename ends with a _number, increment the number
|
| 1475 |
+
const nameParts = path.basename(filename, path.extname(filename)).split('_');
|
| 1476 |
+
const lastPart = nameParts[nameParts.length - 1];
|
| 1477 |
+
|
| 1478 |
+
let baseName;
|
| 1479 |
+
|
| 1480 |
+
if (!isNaN(Number(lastPart)) && nameParts.length > 1) {
|
| 1481 |
+
suffix = parseInt(lastPart) + 1;
|
| 1482 |
+
baseName = nameParts.slice(0, -1).join('_'); // construct baseName without suffix
|
| 1483 |
+
} else {
|
| 1484 |
+
baseName = nameParts.join('_'); // original filename is completely the baseName
|
| 1485 |
+
}
|
| 1486 |
+
|
| 1487 |
+
newFilename = path.join(request.user.directories.characters, `${baseName}_${suffix}${path.extname(filename)}`);
|
| 1488 |
+
|
| 1489 |
+
while (fs.existsSync(newFilename)) {
|
| 1490 |
+
let suffixStr = '_' + suffix;
|
| 1491 |
+
newFilename = path.join(request.user.directories.characters, `${baseName}${suffixStr}${path.extname(filename)}`);
|
| 1492 |
+
suffix++;
|
| 1493 |
+
}
|
| 1494 |
+
|
| 1495 |
+
fs.copyFileSync(filename, newFilename);
|
| 1496 |
+
console.info(`${filename} was copied to ${newFilename}`);
|
| 1497 |
+
response.send({ path: path.parse(newFilename).base });
|
| 1498 |
+
}
|
| 1499 |
+
catch (error) {
|
| 1500 |
+
console.error(error);
|
| 1501 |
+
return response.send({ error: true });
|
| 1502 |
+
}
|
| 1503 |
+
});
|
| 1504 |
+
|
| 1505 |
+
router.post('/export', validateAvatarUrlMiddleware, async function (request, response) {
|
| 1506 |
+
try {
|
| 1507 |
+
if (!request.body.format || !request.body.avatar_url) {
|
| 1508 |
+
return response.sendStatus(400);
|
| 1509 |
+
}
|
| 1510 |
+
|
| 1511 |
+
let filename = path.join(request.user.directories.characters, sanitize(request.body.avatar_url));
|
| 1512 |
+
|
| 1513 |
+
if (!fs.existsSync(filename)) {
|
| 1514 |
+
return response.sendStatus(404);
|
| 1515 |
+
}
|
| 1516 |
+
|
| 1517 |
+
switch (request.body.format) {
|
| 1518 |
+
case 'png': {
|
| 1519 |
+
const rawBuffer = await fsPromises.readFile(filename);
|
| 1520 |
+
const rawData = read(rawBuffer);
|
| 1521 |
+
const mutatedData = mutateJsonString(rawData, unsetPrivateFields);
|
| 1522 |
+
const mutatedBuffer = write(rawBuffer, mutatedData);
|
| 1523 |
+
const contentType = mime.lookup(filename) || 'image/png';
|
| 1524 |
+
response.setHeader('Content-Type', contentType);
|
| 1525 |
+
response.setHeader('Content-Disposition', `attachment; filename="${encodeURI(path.basename(filename))}"`);
|
| 1526 |
+
return response.send(mutatedBuffer);
|
| 1527 |
+
}
|
| 1528 |
+
case 'json': {
|
| 1529 |
+
try {
|
| 1530 |
+
const json = await readCharacterData(filename);
|
| 1531 |
+
if (json === undefined) return response.sendStatus(400);
|
| 1532 |
+
const jsonObject = getCharaCardV2(JSON.parse(json), request.user.directories);
|
| 1533 |
+
unsetPrivateFields(jsonObject);
|
| 1534 |
+
return response.type('json').send(JSON.stringify(jsonObject, null, 4));
|
| 1535 |
+
}
|
| 1536 |
+
catch {
|
| 1537 |
+
return response.sendStatus(400);
|
| 1538 |
+
}
|
| 1539 |
+
}
|
| 1540 |
+
}
|
| 1541 |
+
|
| 1542 |
+
return response.sendStatus(400);
|
| 1543 |
+
} catch (err) {
|
| 1544 |
+
console.error('Character export failed', err);
|
| 1545 |
+
response.sendStatus(500);
|
| 1546 |
+
}
|
| 1547 |
+
});
|
src/endpoints/chats.js
ADDED
|
@@ -0,0 +1,1020 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from 'node:fs';
|
| 2 |
+
import path from 'node:path';
|
| 3 |
+
import readline from 'node:readline';
|
| 4 |
+
import process from 'node:process';
|
| 5 |
+
|
| 6 |
+
import express from 'express';
|
| 7 |
+
import sanitize from 'sanitize-filename';
|
| 8 |
+
import { sync as writeFileAtomicSync } from 'write-file-atomic';
|
| 9 |
+
import _ from 'lodash';
|
| 10 |
+
|
| 11 |
+
import validateAvatarUrlMiddleware from '../middleware/validateFileName.js';
|
| 12 |
+
import {
|
| 13 |
+
getConfigValue,
|
| 14 |
+
humanizedDateTime,
|
| 15 |
+
tryParse,
|
| 16 |
+
generateTimestamp,
|
| 17 |
+
removeOldBackups,
|
| 18 |
+
formatBytes,
|
| 19 |
+
tryWriteFileSync,
|
| 20 |
+
tryReadFileSync,
|
| 21 |
+
tryDeleteFile,
|
| 22 |
+
readFirstLine,
|
| 23 |
+
} from '../util.js';
|
| 24 |
+
|
| 25 |
+
const isBackupEnabled = !!getConfigValue('backups.chat.enabled', true, 'boolean');
|
| 26 |
+
const maxTotalChatBackups = Number(getConfigValue('backups.chat.maxTotalBackups', -1, 'number'));
|
| 27 |
+
const throttleInterval = Number(getConfigValue('backups.chat.throttleInterval', 10_000, 'number'));
|
| 28 |
+
const checkIntegrity = !!getConfigValue('backups.chat.checkIntegrity', true, 'boolean');
|
| 29 |
+
|
| 30 |
+
export const CHAT_BACKUPS_PREFIX = 'chat_';
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* Saves a chat to the backups directory.
|
| 34 |
+
* @param {string} directory The user's backup directory.
|
| 35 |
+
* @param {string} name The name of the chat.
|
| 36 |
+
* @param {string} data The serialized chat to save.
|
| 37 |
+
* @param {string} backupPrefix The file prefix. Typically CHAT_BACKUPS_PREFIX.
|
| 38 |
+
* @returns
|
| 39 |
+
*/
|
| 40 |
+
function backupChat(directory, name, data, backupPrefix = CHAT_BACKUPS_PREFIX) {
|
| 41 |
+
try {
|
| 42 |
+
if (!isBackupEnabled) { return; }
|
| 43 |
+
if (!fs.existsSync(directory)) {
|
| 44 |
+
console.error(`The chat couldn't be backed up because no directory exists at ${directory}!`);
|
| 45 |
+
}
|
| 46 |
+
// replace non-alphanumeric characters with underscores
|
| 47 |
+
name = sanitize(name).replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
| 48 |
+
|
| 49 |
+
const backupFile = path.join(directory, `${backupPrefix}${name}_${generateTimestamp()}.jsonl`);
|
| 50 |
+
|
| 51 |
+
tryWriteFileSync(backupFile, data);
|
| 52 |
+
removeOldBackups(directory, `${backupPrefix}${name}_`);
|
| 53 |
+
if (isNaN(maxTotalChatBackups) || maxTotalChatBackups < 0) {
|
| 54 |
+
return;
|
| 55 |
+
}
|
| 56 |
+
removeOldBackups(directory, backupPrefix, maxTotalChatBackups);
|
| 57 |
+
} catch (err) {
|
| 58 |
+
console.error(`Could not backup chat for ${name}`, err);
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/**
|
| 63 |
+
* @type {Map<string, import('lodash').DebouncedFunc<typeof backupChat>>}
|
| 64 |
+
*/
|
| 65 |
+
const backupFunctions = new Map();
|
| 66 |
+
|
| 67 |
+
/**
|
| 68 |
+
* Gets a backup function for a user.
|
| 69 |
+
* @param {string} handle User handle
|
| 70 |
+
* @returns {typeof backupChat} Backup function
|
| 71 |
+
*/
|
| 72 |
+
function getBackupFunction(handle) {
|
| 73 |
+
if (!backupFunctions.has(handle)) {
|
| 74 |
+
backupFunctions.set(handle, _.throttle(backupChat, throttleInterval, { leading: true, trailing: true }));
|
| 75 |
+
}
|
| 76 |
+
return backupFunctions.get(handle) || (() => { });
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
/**
|
| 80 |
+
* Gets a preview message from an array of chat messages
|
| 81 |
+
* @param {Array<Object>} messages - Array of chat messages, each with a 'mes' property
|
| 82 |
+
* @returns {string} A truncated preview of the last message or empty string if no messages
|
| 83 |
+
*/
|
| 84 |
+
function getPreviewMessage(messages) {
|
| 85 |
+
const strlen = 400;
|
| 86 |
+
const lastMessage = messages[messages.length - 1]?.mes;
|
| 87 |
+
|
| 88 |
+
if (!lastMessage) {
|
| 89 |
+
return '';
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
return lastMessage.length > strlen
|
| 93 |
+
? '...' + lastMessage.substring(lastMessage.length - strlen)
|
| 94 |
+
: lastMessage;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
process.on('exit', () => {
|
| 98 |
+
for (const func of backupFunctions.values()) {
|
| 99 |
+
func.flush();
|
| 100 |
+
}
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
/**
|
| 104 |
+
* Imports a chat from Ooba's format.
|
| 105 |
+
* @param {string} userName User name
|
| 106 |
+
* @param {string} characterName Character name
|
| 107 |
+
* @param {object} jsonData JSON data
|
| 108 |
+
* @returns {string} Chat data
|
| 109 |
+
*/
|
| 110 |
+
function importOobaChat(userName, characterName, jsonData) {
|
| 111 |
+
/** @type {object[]} */
|
| 112 |
+
const chat = [{
|
| 113 |
+
chat_metadata: {},
|
| 114 |
+
user_name: 'unused',
|
| 115 |
+
character_name: 'unused',
|
| 116 |
+
}];
|
| 117 |
+
|
| 118 |
+
for (const arr of jsonData.data_visible) {
|
| 119 |
+
if (arr[0]) {
|
| 120 |
+
const userMessage = {
|
| 121 |
+
name: userName,
|
| 122 |
+
is_user: true,
|
| 123 |
+
send_date: new Date().toISOString(),
|
| 124 |
+
mes: arr[0],
|
| 125 |
+
extra: {},
|
| 126 |
+
};
|
| 127 |
+
chat.push(userMessage);
|
| 128 |
+
}
|
| 129 |
+
if (arr[1]) {
|
| 130 |
+
const charMessage = {
|
| 131 |
+
name: characterName,
|
| 132 |
+
is_user: false,
|
| 133 |
+
send_date: new Date().toISOString(),
|
| 134 |
+
mes: arr[1],
|
| 135 |
+
extra: {},
|
| 136 |
+
};
|
| 137 |
+
chat.push(charMessage);
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
return chat.map(obj => JSON.stringify(obj)).join('\n');
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
/**
|
| 145 |
+
* Imports a chat from Agnai's format.
|
| 146 |
+
* @param {string} userName User name
|
| 147 |
+
* @param {string} characterName Character name
|
| 148 |
+
* @param {object} jsonData Chat data
|
| 149 |
+
* @returns {string} Chat data
|
| 150 |
+
*/
|
| 151 |
+
function importAgnaiChat(userName, characterName, jsonData) {
|
| 152 |
+
/** @type {object[]} */
|
| 153 |
+
const chat = [{
|
| 154 |
+
chat_metadata: {},
|
| 155 |
+
user_name: 'unused',
|
| 156 |
+
character_name: 'unused',
|
| 157 |
+
}];
|
| 158 |
+
|
| 159 |
+
for (const message of jsonData.messages) {
|
| 160 |
+
const isUser = !!message.userId;
|
| 161 |
+
chat.push({
|
| 162 |
+
name: isUser ? userName : characterName,
|
| 163 |
+
is_user: isUser,
|
| 164 |
+
send_date: new Date().toISOString(),
|
| 165 |
+
mes: message.msg,
|
| 166 |
+
extra: {},
|
| 167 |
+
});
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
return chat.map(obj => JSON.stringify(obj)).join('\n');
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
/**
|
| 174 |
+
* Imports a chat from CAI Tools format.
|
| 175 |
+
* @param {string} userName User name
|
| 176 |
+
* @param {string} characterName Character name
|
| 177 |
+
* @param {object} jsonData JSON data
|
| 178 |
+
* @returns {string[]} Converted data
|
| 179 |
+
*/
|
| 180 |
+
function importCAIChat(userName, characterName, jsonData) {
|
| 181 |
+
/**
|
| 182 |
+
* Converts the chat data to suitable format.
|
| 183 |
+
* @param {object} history Imported chat data
|
| 184 |
+
* @returns {object[]} Converted chat data
|
| 185 |
+
*/
|
| 186 |
+
function convert(history) {
|
| 187 |
+
const starter = {
|
| 188 |
+
chat_metadata: {},
|
| 189 |
+
user_name: 'unused',
|
| 190 |
+
character_name: 'unused',
|
| 191 |
+
};
|
| 192 |
+
|
| 193 |
+
const historyData = history.msgs.map((msg) => ({
|
| 194 |
+
name: msg.src.is_human ? userName : characterName,
|
| 195 |
+
is_user: msg.src.is_human,
|
| 196 |
+
send_date: new Date().toISOString(),
|
| 197 |
+
mes: msg.text,
|
| 198 |
+
extra: {},
|
| 199 |
+
}));
|
| 200 |
+
|
| 201 |
+
return [starter, ...historyData];
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
const newChats = (jsonData.histories.histories ?? []).map(history => newChats.push(convert(history).map(obj => JSON.stringify(obj)).join('\n')));
|
| 205 |
+
return newChats;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
/**
|
| 209 |
+
* Imports a chat from Kobold Lite format.
|
| 210 |
+
* @param {string} _userName User name
|
| 211 |
+
* @param {string} _characterName Character name
|
| 212 |
+
* @param {object} data JSON data
|
| 213 |
+
* @returns {string} Chat data
|
| 214 |
+
*/
|
| 215 |
+
function importKoboldLiteChat(_userName, _characterName, data) {
|
| 216 |
+
const inputToken = '{{[INPUT]}}';
|
| 217 |
+
const outputToken = '{{[OUTPUT]}}';
|
| 218 |
+
|
| 219 |
+
/** @type {function(string): object} */
|
| 220 |
+
function processKoboldMessage(msg) {
|
| 221 |
+
const isUser = msg.includes(inputToken);
|
| 222 |
+
return {
|
| 223 |
+
name: isUser ? userName : characterName,
|
| 224 |
+
is_user: isUser,
|
| 225 |
+
mes: msg.replaceAll(inputToken, '').replaceAll(outputToken, '').trim(),
|
| 226 |
+
send_date: new Date().toISOString(),
|
| 227 |
+
extra: {},
|
| 228 |
+
};
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
// Create the header
|
| 232 |
+
const userName = String(data.savedsettings.chatname);
|
| 233 |
+
const characterName = String(data.savedsettings.chatopponent).split('||$||')[0];
|
| 234 |
+
const header = {
|
| 235 |
+
chat_metadata: {},
|
| 236 |
+
user_name: 'unused',
|
| 237 |
+
character_name: 'unused',
|
| 238 |
+
};
|
| 239 |
+
// Format messages
|
| 240 |
+
const formattedMessages = data.actions.map(processKoboldMessage);
|
| 241 |
+
// Add prompt if available
|
| 242 |
+
if (data.prompt) {
|
| 243 |
+
formattedMessages.unshift(processKoboldMessage(data.prompt));
|
| 244 |
+
}
|
| 245 |
+
// Combine header and messages
|
| 246 |
+
const chatData = [header, ...formattedMessages];
|
| 247 |
+
return chatData.map(obj => JSON.stringify(obj)).join('\n');
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
/**
|
| 251 |
+
* Flattens `msg` and `swipes` data from Chub Chat format.
|
| 252 |
+
* Only changes enough to make it compatible with the standard chat serialization format.
|
| 253 |
+
* @param {string} userName User name
|
| 254 |
+
* @param {string} characterName Character name
|
| 255 |
+
* @param {string[]} lines serialised JSONL data
|
| 256 |
+
* @returns {string} Converted data
|
| 257 |
+
*/
|
| 258 |
+
function flattenChubChat(userName, characterName, lines) {
|
| 259 |
+
function flattenSwipe(swipe) {
|
| 260 |
+
return swipe.message ? swipe.message : swipe;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
function convert(line) {
|
| 264 |
+
const lineData = tryParse(line);
|
| 265 |
+
if (!lineData) return line;
|
| 266 |
+
|
| 267 |
+
if (lineData.mes && lineData.mes.message) {
|
| 268 |
+
lineData.mes = lineData?.mes.message;
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
if (lineData?.swipes && Array.isArray(lineData.swipes)) {
|
| 272 |
+
lineData.swipes = lineData.swipes.map(swipe => flattenSwipe(swipe));
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
return JSON.stringify(lineData);
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
return (lines ?? []).map(convert).join('\n');
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
/**
|
| 282 |
+
* Imports a chat from RisuAI format.
|
| 283 |
+
* @param {string} userName User name
|
| 284 |
+
* @param {string} characterName Character name
|
| 285 |
+
* @param {object} jsonData Imported chat data
|
| 286 |
+
* @returns {string} Chat data
|
| 287 |
+
*/
|
| 288 |
+
function importRisuChat(userName, characterName, jsonData) {
|
| 289 |
+
/** @type {object[]} */
|
| 290 |
+
const chat = [{
|
| 291 |
+
chat_metadata: {},
|
| 292 |
+
user_name: 'unused',
|
| 293 |
+
character_name: 'unused',
|
| 294 |
+
}];
|
| 295 |
+
|
| 296 |
+
for (const message of jsonData.data.message) {
|
| 297 |
+
const isUser = message.role === 'user';
|
| 298 |
+
chat.push({
|
| 299 |
+
name: message.name ?? (isUser ? userName : characterName),
|
| 300 |
+
is_user: isUser,
|
| 301 |
+
send_date: new Date(Number(message.time ?? Date.now())).toISOString(),
|
| 302 |
+
mes: message.data ?? '',
|
| 303 |
+
extra: {},
|
| 304 |
+
});
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
return chat.map(obj => JSON.stringify(obj)).join('\n');
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
/**
|
| 311 |
+
* Checks if the chat being saved has the same integrity as the one being loaded.
|
| 312 |
+
* @param {string} filePath Path to the chat file
|
| 313 |
+
* @param {string} integritySlug Integrity slug
|
| 314 |
+
* @returns {Promise<boolean>} Whether the chat is intact
|
| 315 |
+
*/
|
| 316 |
+
async function checkChatIntegrity(filePath, integritySlug) {
|
| 317 |
+
// If the chat file doesn't exist, assume it's intact
|
| 318 |
+
if (!fs.existsSync(filePath)) {
|
| 319 |
+
return true;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
// Parse the first line of the chat file as JSON
|
| 323 |
+
const firstLine = await readFirstLine(filePath);
|
| 324 |
+
const jsonData = tryParse(firstLine);
|
| 325 |
+
const chatIntegrity = jsonData?.chat_metadata?.integrity;
|
| 326 |
+
|
| 327 |
+
// If the chat has no integrity metadata, assume it's intact
|
| 328 |
+
if (!chatIntegrity) {
|
| 329 |
+
console.debug(`File "${filePath}" does not have integrity metadata matching "${integritySlug}". The integrity validation has been skipped.`);
|
| 330 |
+
return true;
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
// Check if the integrity matches
|
| 334 |
+
return chatIntegrity === integritySlug;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
/**
|
| 338 |
+
* @typedef {Object} ChatInfo
|
| 339 |
+
* @property {string} [file_id] - The name of the chat file (without extension)
|
| 340 |
+
* @property {string} [file_name] - The name of the chat file (with extension)
|
| 341 |
+
* @property {string} [file_size] - The size of the chat file in a human-readable format
|
| 342 |
+
* @property {number} [chat_items] - The number of chat items in the file
|
| 343 |
+
* @property {string} [mes] - The last message in the chat
|
| 344 |
+
* @property {number} [last_mes] - The timestamp of the last message
|
| 345 |
+
* @property {object} [chat_metadata] - Additional chat metadata
|
| 346 |
+
*/
|
| 347 |
+
|
| 348 |
+
/**
|
| 349 |
+
* Reads the information from a chat file.
|
| 350 |
+
* @param {string} pathToFile - Path to the chat file
|
| 351 |
+
* @param {object} additionalData - Additional data to include in the result
|
| 352 |
+
* @param {boolean} withMetadata - Whether to read chat metadata
|
| 353 |
+
* @returns {Promise<ChatInfo>}
|
| 354 |
+
*/
|
| 355 |
+
export async function getChatInfo(pathToFile, additionalData = {}, withMetadata = false) {
|
| 356 |
+
return new Promise(async (res) => {
|
| 357 |
+
const parsedPath = path.parse(pathToFile);
|
| 358 |
+
const stats = await fs.promises.stat(pathToFile);
|
| 359 |
+
|
| 360 |
+
const chatData = {
|
| 361 |
+
file_id: parsedPath.name,
|
| 362 |
+
file_name: parsedPath.base,
|
| 363 |
+
file_size: formatBytes(stats.size),
|
| 364 |
+
chat_items: 0,
|
| 365 |
+
mes: '[The chat is empty]',
|
| 366 |
+
last_mes: stats.mtimeMs,
|
| 367 |
+
...additionalData,
|
| 368 |
+
};
|
| 369 |
+
|
| 370 |
+
if (stats.size === 0) {
|
| 371 |
+
res(chatData);
|
| 372 |
+
return;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
const fileStream = fs.createReadStream(pathToFile);
|
| 376 |
+
const rl = readline.createInterface({
|
| 377 |
+
input: fileStream,
|
| 378 |
+
crlfDelay: Infinity,
|
| 379 |
+
});
|
| 380 |
+
|
| 381 |
+
let lastLine;
|
| 382 |
+
let itemCounter = 0;
|
| 383 |
+
rl.on('line', (line) => {
|
| 384 |
+
if (withMetadata && itemCounter === 0) {
|
| 385 |
+
const jsonData = tryParse(line);
|
| 386 |
+
if (jsonData && _.isObjectLike(jsonData.chat_metadata)) {
|
| 387 |
+
chatData.chat_metadata = jsonData.chat_metadata;
|
| 388 |
+
}
|
| 389 |
+
}
|
| 390 |
+
itemCounter++;
|
| 391 |
+
lastLine = line;
|
| 392 |
+
});
|
| 393 |
+
rl.on('close', () => {
|
| 394 |
+
rl.close();
|
| 395 |
+
|
| 396 |
+
if (lastLine) {
|
| 397 |
+
const jsonData = tryParse(lastLine);
|
| 398 |
+
if (jsonData && (jsonData.name || jsonData.character_name || jsonData.chat_metadata)) {
|
| 399 |
+
chatData.chat_items = (itemCounter - 1);
|
| 400 |
+
chatData.mes = jsonData['mes'] || '[The message is empty]';
|
| 401 |
+
chatData.last_mes = jsonData['send_date'] || new Date(Math.round(stats.mtimeMs)).toISOString();
|
| 402 |
+
|
| 403 |
+
res(chatData);
|
| 404 |
+
} else {
|
| 405 |
+
console.warn('Found an invalid or corrupted chat file:', pathToFile);
|
| 406 |
+
res({});
|
| 407 |
+
}
|
| 408 |
+
}
|
| 409 |
+
});
|
| 410 |
+
});
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
export const router = express.Router();
|
| 414 |
+
|
| 415 |
+
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error
|
| 416 |
+
class IntegrityMismatchError extends Error {
|
| 417 |
+
constructor(...params) {
|
| 418 |
+
// Pass remaining arguments (including vendor specific ones) to parent constructor
|
| 419 |
+
super(...params);
|
| 420 |
+
// Maintains proper stack trace for where our error was thrown (non-standard)
|
| 421 |
+
if (Error.captureStackTrace) {
|
| 422 |
+
Error.captureStackTrace(this, IntegrityMismatchError);
|
| 423 |
+
}
|
| 424 |
+
this.date = new Date();
|
| 425 |
+
}
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
/**
|
| 429 |
+
* Tries to save the chat data to a file, performing an integrity check if required.
|
| 430 |
+
* @param {Array} chatData The chat array to save.
|
| 431 |
+
* @param {string} filePath Target file path for the data.
|
| 432 |
+
* @param {boolean} skipIntegrityCheck If undefined, the chat's integrity will not be checked.
|
| 433 |
+
* @param {string} handle The users handle, passed to getBackupFunction.
|
| 434 |
+
* @param {string} cardName Passed to backupChat.
|
| 435 |
+
* @param {string} backupDirectory Passed to backupChat.
|
| 436 |
+
*/
|
| 437 |
+
export async function trySaveChat(chatData, filePath, skipIntegrityCheck = false, handle, cardName, backupDirectory) {
|
| 438 |
+
const jsonlData = chatData?.map(m => JSON.stringify(m)).join('\n');
|
| 439 |
+
|
| 440 |
+
const doIntegrityCheck = (checkIntegrity && !skipIntegrityCheck);
|
| 441 |
+
const chatIntegritySlug = doIntegrityCheck ? chatData?.[0]?.chat_metadata?.integrity : undefined;
|
| 442 |
+
|
| 443 |
+
if (chatIntegritySlug && !await checkChatIntegrity(filePath, chatIntegritySlug)) {
|
| 444 |
+
throw new IntegrityMismatchError(`Chat integrity check failed for "${filePath}". The expected integrity slug was "${chatIntegritySlug}".`);
|
| 445 |
+
}
|
| 446 |
+
tryWriteFileSync(filePath, jsonlData);
|
| 447 |
+
getBackupFunction(handle)(backupDirectory, cardName, jsonlData);
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
router.post('/save', validateAvatarUrlMiddleware, async function (request, response) {
|
| 451 |
+
try {
|
| 452 |
+
const handle = request.user.profile.handle;
|
| 453 |
+
const cardName = String(request.body.avatar_url).replace('.png', '');
|
| 454 |
+
const chatData = request.body.chat;
|
| 455 |
+
const chatFileName = `${String(request.body.file_name)}.jsonl`;
|
| 456 |
+
const chatFilePath = path.join(request.user.directories.chats, cardName, sanitize(chatFileName));
|
| 457 |
+
|
| 458 |
+
if (Array.isArray(chatData)) {
|
| 459 |
+
await trySaveChat(chatData, chatFilePath, request.body.force, handle, cardName, request.user.directories.backups);
|
| 460 |
+
return response.send({ ok: true });
|
| 461 |
+
} else {
|
| 462 |
+
return response.status(400).send({ error: 'The request\'s body.chat is not an array.' });
|
| 463 |
+
}
|
| 464 |
+
} catch (error) {
|
| 465 |
+
if (error instanceof IntegrityMismatchError) {
|
| 466 |
+
console.error(error.message);
|
| 467 |
+
return response.status(400).send({ error: 'integrity' });
|
| 468 |
+
}
|
| 469 |
+
console.error(error);
|
| 470 |
+
return response.status(500).send({ error: 'An error has occurred, see the console logs for more information.' });
|
| 471 |
+
}
|
| 472 |
+
});
|
| 473 |
+
|
| 474 |
+
/**
|
| 475 |
+
* Gets the chat as an object.
|
| 476 |
+
* @param {string} chatFilePath The full chat file path.
|
| 477 |
+
* @returns {Array}} If the chatFilePath cannot be read, this will return [].
|
| 478 |
+
*/
|
| 479 |
+
export function getChatData(chatFilePath) {
|
| 480 |
+
let chatData = [];
|
| 481 |
+
|
| 482 |
+
const chatJSON = tryReadFileSync(chatFilePath) ?? '';
|
| 483 |
+
if (chatJSON.length > 0) {
|
| 484 |
+
const lines = chatJSON.split('\n');
|
| 485 |
+
// Iterate through the array of strings and parse each line as JSON
|
| 486 |
+
chatData = lines.map(line => tryParse(line)).filter(x => x);
|
| 487 |
+
} else {
|
| 488 |
+
console.warn(`File not found: ${chatFilePath}. The chat does not exist or is empty.`);
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
return chatData;
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
router.post('/get', validateAvatarUrlMiddleware, function (request, response) {
|
| 495 |
+
try {
|
| 496 |
+
const dirName = String(request.body.avatar_url).replace('.png', '');
|
| 497 |
+
const directoryPath = path.join(request.user.directories.chats, dirName);
|
| 498 |
+
const chatDirExists = fs.existsSync(directoryPath);
|
| 499 |
+
|
| 500 |
+
//if no chat dir for the character is found, make one with the character name
|
| 501 |
+
if (!chatDirExists) {
|
| 502 |
+
fs.mkdirSync(directoryPath);
|
| 503 |
+
return response.send({});
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
if (!request.body.file_name) {
|
| 507 |
+
return response.send({});
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
const chatFileName = `${String(request.body.file_name)}.jsonl`;
|
| 511 |
+
const chatFilePath = path.join(directoryPath, sanitize(chatFileName));
|
| 512 |
+
|
| 513 |
+
return response.send(getChatData(chatFilePath));
|
| 514 |
+
} catch (error) {
|
| 515 |
+
console.error(error);
|
| 516 |
+
return response.send({});
|
| 517 |
+
}
|
| 518 |
+
});
|
| 519 |
+
|
| 520 |
+
router.post('/rename', validateAvatarUrlMiddleware, async function (request, response) {
|
| 521 |
+
try {
|
| 522 |
+
if (!request.body || !request.body.original_file || !request.body.renamed_file) {
|
| 523 |
+
return response.sendStatus(400);
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
const pathToFolder = request.body.is_group
|
| 527 |
+
? request.user.directories.groupChats
|
| 528 |
+
: path.join(request.user.directories.chats, String(request.body.avatar_url).replace('.png', ''));
|
| 529 |
+
const pathToOriginalFile = path.join(pathToFolder, sanitize(request.body.original_file));
|
| 530 |
+
const pathToRenamedFile = path.join(pathToFolder, sanitize(request.body.renamed_file));
|
| 531 |
+
const sanitizedFileName = path.parse(pathToRenamedFile).name;
|
| 532 |
+
console.debug('Old chat name', pathToOriginalFile);
|
| 533 |
+
console.debug('New chat name', pathToRenamedFile);
|
| 534 |
+
|
| 535 |
+
if (!fs.existsSync(pathToOriginalFile) || fs.existsSync(pathToRenamedFile)) {
|
| 536 |
+
console.error('Either Source or Destination files are not available');
|
| 537 |
+
return response.status(400).send({ error: true });
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
fs.copyFileSync(pathToOriginalFile, pathToRenamedFile);
|
| 541 |
+
fs.unlinkSync(pathToOriginalFile);
|
| 542 |
+
console.info('Successfully renamed chat file.');
|
| 543 |
+
return response.send({ ok: true, sanitizedFileName });
|
| 544 |
+
} catch (error) {
|
| 545 |
+
console.error('Error renaming chat file:', error);
|
| 546 |
+
return response.status(500).send({ error: true });
|
| 547 |
+
}
|
| 548 |
+
});
|
| 549 |
+
|
| 550 |
+
router.post('/delete', validateAvatarUrlMiddleware, function (request, response) {
|
| 551 |
+
try {
|
| 552 |
+
if (!path.extname(request.body.chatfile)) {
|
| 553 |
+
request.body.chatfile += '.jsonl';
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
const dirName = String(request.body.avatar_url).replace('.png', '');
|
| 557 |
+
const chatFileName = String(request.body.chatfile);
|
| 558 |
+
const chatFilePath = path.join(request.user.directories.chats, dirName, sanitize(chatFileName));
|
| 559 |
+
//Return success if the file was deleted.
|
| 560 |
+
if (tryDeleteFile(chatFilePath)) {
|
| 561 |
+
return response.send({ ok: true });
|
| 562 |
+
} else {
|
| 563 |
+
console.error('The chat file was not deleted.');
|
| 564 |
+
return response.sendStatus(400);
|
| 565 |
+
}
|
| 566 |
+
} catch (error) {
|
| 567 |
+
console.error(error);
|
| 568 |
+
return response.sendStatus(500);
|
| 569 |
+
}
|
| 570 |
+
});
|
| 571 |
+
|
| 572 |
+
router.post('/export', validateAvatarUrlMiddleware, async function (request, response) {
|
| 573 |
+
if (!request.body.file || (!request.body.avatar_url && request.body.is_group === false)) {
|
| 574 |
+
return response.sendStatus(400);
|
| 575 |
+
}
|
| 576 |
+
const pathToFolder = request.body.is_group
|
| 577 |
+
? request.user.directories.groupChats
|
| 578 |
+
: path.join(request.user.directories.chats, String(request.body.avatar_url).replace('.png', ''));
|
| 579 |
+
let filename = path.join(pathToFolder, request.body.file);
|
| 580 |
+
let exportfilename = request.body.exportfilename;
|
| 581 |
+
if (!fs.existsSync(filename)) {
|
| 582 |
+
const errorMessage = {
|
| 583 |
+
message: `Could not find JSONL file to export. Source chat file: ${filename}.`,
|
| 584 |
+
};
|
| 585 |
+
console.error(errorMessage.message);
|
| 586 |
+
return response.status(404).json(errorMessage);
|
| 587 |
+
}
|
| 588 |
+
try {
|
| 589 |
+
// Short path for JSONL files
|
| 590 |
+
if (request.body.format === 'jsonl') {
|
| 591 |
+
try {
|
| 592 |
+
const rawFile = fs.readFileSync(filename, 'utf8');
|
| 593 |
+
const successMessage = {
|
| 594 |
+
message: `Chat saved to ${exportfilename}`,
|
| 595 |
+
result: rawFile,
|
| 596 |
+
};
|
| 597 |
+
|
| 598 |
+
console.info(`Chat exported as ${exportfilename}`);
|
| 599 |
+
return response.status(200).json(successMessage);
|
| 600 |
+
} catch (err) {
|
| 601 |
+
console.error(err);
|
| 602 |
+
const errorMessage = {
|
| 603 |
+
message: `Could not read JSONL file to export. Source chat file: ${filename}.`,
|
| 604 |
+
};
|
| 605 |
+
console.error(errorMessage.message);
|
| 606 |
+
return response.status(500).json(errorMessage);
|
| 607 |
+
}
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
const readStream = fs.createReadStream(filename);
|
| 611 |
+
const rl = readline.createInterface({
|
| 612 |
+
input: readStream,
|
| 613 |
+
});
|
| 614 |
+
let buffer = '';
|
| 615 |
+
rl.on('line', (line) => {
|
| 616 |
+
const data = JSON.parse(line);
|
| 617 |
+
// Skip non-printable/prompt-hidden messages
|
| 618 |
+
if (data.is_system) {
|
| 619 |
+
return;
|
| 620 |
+
}
|
| 621 |
+
if (data.mes) {
|
| 622 |
+
const name = data.name;
|
| 623 |
+
const message = (data?.extra?.display_text || data?.mes || '').replace(/\r?\n/g, '\n');
|
| 624 |
+
buffer += (`${name}: ${message}\n\n`);
|
| 625 |
+
}
|
| 626 |
+
});
|
| 627 |
+
rl.on('close', () => {
|
| 628 |
+
const successMessage = {
|
| 629 |
+
message: `Chat saved to ${exportfilename}`,
|
| 630 |
+
result: buffer,
|
| 631 |
+
};
|
| 632 |
+
console.info(`Chat exported as ${exportfilename}`);
|
| 633 |
+
return response.status(200).json(successMessage);
|
| 634 |
+
});
|
| 635 |
+
} catch (err) {
|
| 636 |
+
console.error('chat export failed.', err);
|
| 637 |
+
return response.sendStatus(400);
|
| 638 |
+
}
|
| 639 |
+
});
|
| 640 |
+
|
| 641 |
+
router.post('/group/import', function (request, response) {
|
| 642 |
+
try {
|
| 643 |
+
const filedata = request.file;
|
| 644 |
+
|
| 645 |
+
if (!filedata) {
|
| 646 |
+
return response.sendStatus(400);
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
const chatname = humanizedDateTime();
|
| 650 |
+
const pathToUpload = path.join(filedata.destination, filedata.filename);
|
| 651 |
+
const pathToNewFile = path.join(request.user.directories.groupChats, `${chatname}.jsonl`);
|
| 652 |
+
fs.copyFileSync(pathToUpload, pathToNewFile);
|
| 653 |
+
fs.unlinkSync(pathToUpload);
|
| 654 |
+
return response.send({ res: chatname });
|
| 655 |
+
} catch (error) {
|
| 656 |
+
console.error(error);
|
| 657 |
+
return response.send({ error: true });
|
| 658 |
+
}
|
| 659 |
+
});
|
| 660 |
+
|
| 661 |
+
router.post('/import', validateAvatarUrlMiddleware, function (request, response) {
|
| 662 |
+
if (!request.body) return response.sendStatus(400);
|
| 663 |
+
|
| 664 |
+
const format = request.body.file_type;
|
| 665 |
+
const avatarUrl = (request.body.avatar_url).replace('.png', '');
|
| 666 |
+
const characterName = request.body.character_name;
|
| 667 |
+
const userName = request.body.user_name || 'User';
|
| 668 |
+
const fileNames = [];
|
| 669 |
+
|
| 670 |
+
if (!request.file) {
|
| 671 |
+
return response.sendStatus(400);
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
try {
|
| 675 |
+
const pathToUpload = path.join(request.file.destination, request.file.filename);
|
| 676 |
+
const data = fs.readFileSync(pathToUpload, 'utf8');
|
| 677 |
+
|
| 678 |
+
if (format === 'json') {
|
| 679 |
+
fs.unlinkSync(pathToUpload);
|
| 680 |
+
const jsonData = JSON.parse(data);
|
| 681 |
+
|
| 682 |
+
/** @type {function(string, string, object): string|string[]} */
|
| 683 |
+
let importFunc;
|
| 684 |
+
|
| 685 |
+
if (jsonData.savedsettings !== undefined) { // Kobold Lite format
|
| 686 |
+
importFunc = importKoboldLiteChat;
|
| 687 |
+
} else if (jsonData.histories !== undefined) { // CAI Tools format
|
| 688 |
+
importFunc = importCAIChat;
|
| 689 |
+
} else if (Array.isArray(jsonData.data_visible)) { // oobabooga's format
|
| 690 |
+
importFunc = importOobaChat;
|
| 691 |
+
} else if (Array.isArray(jsonData.messages)) { // Agnai's format
|
| 692 |
+
importFunc = importAgnaiChat;
|
| 693 |
+
} else if (jsonData.type === 'risuChat') { // RisuAI format
|
| 694 |
+
importFunc = importRisuChat;
|
| 695 |
+
} else { // Unknown format
|
| 696 |
+
console.error('Incorrect chat format .json');
|
| 697 |
+
return response.send({ error: true });
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
const handleChat = (chat) => {
|
| 701 |
+
const fileName = `${characterName} - ${humanizedDateTime()} imported.jsonl`;
|
| 702 |
+
const filePath = path.join(request.user.directories.chats, avatarUrl, fileName);
|
| 703 |
+
fileNames.push(fileName);
|
| 704 |
+
writeFileAtomicSync(filePath, chat, 'utf8');
|
| 705 |
+
};
|
| 706 |
+
|
| 707 |
+
const chat = importFunc(userName, characterName, jsonData);
|
| 708 |
+
|
| 709 |
+
if (Array.isArray(chat)) {
|
| 710 |
+
chat.forEach(handleChat);
|
| 711 |
+
} else {
|
| 712 |
+
handleChat(chat);
|
| 713 |
+
}
|
| 714 |
+
|
| 715 |
+
return response.send({ res: true, fileNames });
|
| 716 |
+
}
|
| 717 |
+
|
| 718 |
+
if (format === 'jsonl') {
|
| 719 |
+
let lines = data.split('\n');
|
| 720 |
+
const header = lines[0];
|
| 721 |
+
|
| 722 |
+
const jsonData = JSON.parse(header);
|
| 723 |
+
|
| 724 |
+
if (!(jsonData.user_name !== undefined || jsonData.name !== undefined || jsonData.chat_metadata !== undefined)) {
|
| 725 |
+
console.error('Incorrect chat format .jsonl');
|
| 726 |
+
return response.send({ error: true });
|
| 727 |
+
}
|
| 728 |
+
|
| 729 |
+
// Do a tiny bit of work to import Chub Chat data
|
| 730 |
+
// Processing the entire file is so fast that it's not worth checking if it's a Chub chat first
|
| 731 |
+
let flattenedChat = data;
|
| 732 |
+
try {
|
| 733 |
+
// flattening is unlikely to break, but it's not worth failing to
|
| 734 |
+
// import normal chats in an attempt to import a Chub chat
|
| 735 |
+
flattenedChat = flattenChubChat(userName, characterName, lines);
|
| 736 |
+
} catch (error) {
|
| 737 |
+
console.warn('Failed to flatten Chub Chat data: ', error);
|
| 738 |
+
}
|
| 739 |
+
|
| 740 |
+
const fileName = `${characterName} - ${humanizedDateTime()} imported.jsonl`;
|
| 741 |
+
const filePath = path.join(request.user.directories.chats, avatarUrl, fileName);
|
| 742 |
+
fileNames.push(fileName);
|
| 743 |
+
if (flattenedChat !== data) {
|
| 744 |
+
writeFileAtomicSync(filePath, flattenedChat, 'utf8');
|
| 745 |
+
} else {
|
| 746 |
+
fs.copyFileSync(pathToUpload, filePath);
|
| 747 |
+
}
|
| 748 |
+
fs.unlinkSync(pathToUpload);
|
| 749 |
+
response.send({ res: true, fileNames });
|
| 750 |
+
}
|
| 751 |
+
} catch (error) {
|
| 752 |
+
console.error(error);
|
| 753 |
+
return response.send({ error: true });
|
| 754 |
+
}
|
| 755 |
+
});
|
| 756 |
+
|
| 757 |
+
router.post('/group/get', (request, response) => {
|
| 758 |
+
if (!request.body || !request.body.id) {
|
| 759 |
+
return response.sendStatus(400);
|
| 760 |
+
}
|
| 761 |
+
|
| 762 |
+
const id = request.body.id;
|
| 763 |
+
const chatFilePath = path.join(request.user.directories.groupChats, `${id}.jsonl`);
|
| 764 |
+
|
| 765 |
+
return response.send(getChatData(chatFilePath));
|
| 766 |
+
});
|
| 767 |
+
|
| 768 |
+
router.post('/group/delete', (request, response) => {
|
| 769 |
+
try {
|
| 770 |
+
if (!request.body || !request.body.id) {
|
| 771 |
+
return response.sendStatus(400);
|
| 772 |
+
}
|
| 773 |
+
|
| 774 |
+
const id = request.body.id;
|
| 775 |
+
const chatFilePath = path.join(request.user.directories.groupChats, `${id}.jsonl`);
|
| 776 |
+
|
| 777 |
+
//Return success if the file was deleted.
|
| 778 |
+
if (tryDeleteFile(chatFilePath)) {
|
| 779 |
+
return response.send({ ok: true });
|
| 780 |
+
} else {
|
| 781 |
+
console.error('The group chat file was not deleted.\'');
|
| 782 |
+
return response.sendStatus(400);
|
| 783 |
+
}
|
| 784 |
+
} catch (error) {
|
| 785 |
+
console.error(error);
|
| 786 |
+
return response.sendStatus(500);
|
| 787 |
+
}
|
| 788 |
+
});
|
| 789 |
+
|
| 790 |
+
router.post('/group/save', async function (request, response) {
|
| 791 |
+
try {
|
| 792 |
+
if (!request.body || !request.body.id) {
|
| 793 |
+
return response.sendStatus(400);
|
| 794 |
+
}
|
| 795 |
+
|
| 796 |
+
const id = request.body.id;
|
| 797 |
+
const handle = request.user.profile.handle;
|
| 798 |
+
const chatFilePath = path.join(request.user.directories.groupChats, sanitize(`${id}.jsonl`));
|
| 799 |
+
const chatData = request.body.chat;
|
| 800 |
+
|
| 801 |
+
if (Array.isArray(chatData)) {
|
| 802 |
+
await trySaveChat(chatData, chatFilePath, request.body.force, handle, String(id), request.user.directories.backups);
|
| 803 |
+
return response.send({ ok: true });
|
| 804 |
+
}
|
| 805 |
+
else {
|
| 806 |
+
return response.status(400).send({ error: 'The request\'s body.chat is not an array.' });
|
| 807 |
+
}
|
| 808 |
+
} catch (error) {
|
| 809 |
+
if (error instanceof IntegrityMismatchError) {
|
| 810 |
+
console.error(error.message);
|
| 811 |
+
return response.status(400).send({ error: 'integrity' });
|
| 812 |
+
}
|
| 813 |
+
console.error(error);
|
| 814 |
+
return response.status(500).send({ error: 'An error has occurred, see the console logs for more information.' });
|
| 815 |
+
}
|
| 816 |
+
});
|
| 817 |
+
|
| 818 |
+
router.post('/search', validateAvatarUrlMiddleware, function (request, response) {
|
| 819 |
+
try {
|
| 820 |
+
const { query, avatar_url, group_id } = request.body;
|
| 821 |
+
let chatFiles = [];
|
| 822 |
+
|
| 823 |
+
if (group_id) {
|
| 824 |
+
// Find group's chat IDs first
|
| 825 |
+
const groupDir = path.join(request.user.directories.groups);
|
| 826 |
+
const groupFiles = fs.readdirSync(groupDir)
|
| 827 |
+
.filter(file => file.endsWith('.json'));
|
| 828 |
+
|
| 829 |
+
let targetGroup;
|
| 830 |
+
for (const groupFile of groupFiles) {
|
| 831 |
+
try {
|
| 832 |
+
const groupData = JSON.parse(fs.readFileSync(path.join(groupDir, groupFile), 'utf8'));
|
| 833 |
+
if (groupData.id === group_id) {
|
| 834 |
+
targetGroup = groupData;
|
| 835 |
+
break;
|
| 836 |
+
}
|
| 837 |
+
} catch (error) {
|
| 838 |
+
console.warn(groupFile, 'group file is corrupted:', error);
|
| 839 |
+
}
|
| 840 |
+
}
|
| 841 |
+
|
| 842 |
+
if (!targetGroup?.chats) {
|
| 843 |
+
return response.send([]);
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
// Find group chat files for given group ID
|
| 847 |
+
const groupChatsDir = path.join(request.user.directories.groupChats);
|
| 848 |
+
chatFiles = targetGroup.chats
|
| 849 |
+
.map(chatId => {
|
| 850 |
+
const filePath = path.join(groupChatsDir, `${chatId}.jsonl`);
|
| 851 |
+
if (!fs.existsSync(filePath)) return null;
|
| 852 |
+
const stats = fs.statSync(filePath);
|
| 853 |
+
return {
|
| 854 |
+
file_name: chatId,
|
| 855 |
+
file_size: formatBytes(stats.size),
|
| 856 |
+
path: filePath,
|
| 857 |
+
};
|
| 858 |
+
})
|
| 859 |
+
.filter(x => x);
|
| 860 |
+
} else {
|
| 861 |
+
// Regular character chat directory
|
| 862 |
+
const character_name = avatar_url.replace('.png', '');
|
| 863 |
+
const directoryPath = path.join(request.user.directories.chats, character_name);
|
| 864 |
+
|
| 865 |
+
if (!fs.existsSync(directoryPath)) {
|
| 866 |
+
return response.send([]);
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
+
chatFiles = fs.readdirSync(directoryPath)
|
| 870 |
+
.filter(file => file.endsWith('.jsonl'))
|
| 871 |
+
.map(fileName => {
|
| 872 |
+
const filePath = path.join(directoryPath, fileName);
|
| 873 |
+
const stats = fs.statSync(filePath);
|
| 874 |
+
return {
|
| 875 |
+
file_name: fileName,
|
| 876 |
+
file_size: formatBytes(stats.size),
|
| 877 |
+
path: filePath,
|
| 878 |
+
};
|
| 879 |
+
});
|
| 880 |
+
}
|
| 881 |
+
|
| 882 |
+
const results = [];
|
| 883 |
+
|
| 884 |
+
// Search logic
|
| 885 |
+
for (const chatFile of chatFiles) {
|
| 886 |
+
const data = getChatData(chatFile.path);
|
| 887 |
+
const messages = data.filter(x => x && typeof x.mes === 'string');
|
| 888 |
+
|
| 889 |
+
if (query && messages.length === 0) {
|
| 890 |
+
continue;
|
| 891 |
+
}
|
| 892 |
+
|
| 893 |
+
const lastMessage = messages[messages.length - 1];
|
| 894 |
+
const lastMesDate = lastMessage?.send_date || new Date(fs.statSync(chatFile.path).mtimeMs).toISOString();
|
| 895 |
+
|
| 896 |
+
// If no search query, just return metadata
|
| 897 |
+
if (!query) {
|
| 898 |
+
results.push({
|
| 899 |
+
file_name: chatFile.file_name,
|
| 900 |
+
file_size: chatFile.file_size,
|
| 901 |
+
message_count: messages.length,
|
| 902 |
+
last_mes: lastMesDate,
|
| 903 |
+
preview_message: getPreviewMessage(messages),
|
| 904 |
+
});
|
| 905 |
+
continue;
|
| 906 |
+
}
|
| 907 |
+
|
| 908 |
+
// Search through title and messages of the chat
|
| 909 |
+
const fragments = query.trim().toLowerCase().split(/\s+/).filter(x => x);
|
| 910 |
+
const text = [path.parse(chatFile.path).name, ...messages.map(message => message?.mes)].join('\n').toLowerCase();
|
| 911 |
+
const hasMatch = fragments.every(fragment => text.includes(fragment));
|
| 912 |
+
|
| 913 |
+
if (hasMatch) {
|
| 914 |
+
results.push({
|
| 915 |
+
file_name: chatFile.file_name,
|
| 916 |
+
file_size: chatFile.file_size,
|
| 917 |
+
message_count: messages.length,
|
| 918 |
+
last_mes: lastMesDate,
|
| 919 |
+
preview_message: getPreviewMessage(messages),
|
| 920 |
+
});
|
| 921 |
+
}
|
| 922 |
+
}
|
| 923 |
+
|
| 924 |
+
// Sort by last message date descending
|
| 925 |
+
results.sort((a, b) => new Date(b.last_mes).getTime() - new Date(a.last_mes).getTime());
|
| 926 |
+
return response.send(results);
|
| 927 |
+
|
| 928 |
+
} catch (error) {
|
| 929 |
+
console.error('Chat search error:', error);
|
| 930 |
+
return response.status(500).json({ error: 'Search failed' });
|
| 931 |
+
}
|
| 932 |
+
});
|
| 933 |
+
|
| 934 |
+
router.post('/recent', async function (request, response) {
|
| 935 |
+
try {
|
| 936 |
+
/** @type {{pngFile?: string, groupId?: string, filePath: string, mtime: number}[]} */
|
| 937 |
+
const allChatFiles = [];
|
| 938 |
+
|
| 939 |
+
const getCharacterChatFiles = async () => {
|
| 940 |
+
const pngDirents = await fs.promises.readdir(request.user.directories.characters, { withFileTypes: true });
|
| 941 |
+
const pngFiles = pngDirents.filter(e => e.isFile() && path.extname(e.name) === '.png').map(e => e.name);
|
| 942 |
+
|
| 943 |
+
for (const pngFile of pngFiles) {
|
| 944 |
+
const chatsDirectory = pngFile.replace('.png', '');
|
| 945 |
+
const pathToChats = path.join(request.user.directories.chats, chatsDirectory);
|
| 946 |
+
if (!fs.existsSync(pathToChats)) {
|
| 947 |
+
continue;
|
| 948 |
+
}
|
| 949 |
+
const pathStats = await fs.promises.stat(pathToChats);
|
| 950 |
+
if (pathStats.isDirectory()) {
|
| 951 |
+
const chatFiles = await fs.promises.readdir(pathToChats);
|
| 952 |
+
const jsonlFiles = chatFiles.filter(file => path.extname(file) === '.jsonl');
|
| 953 |
+
|
| 954 |
+
for (const file of jsonlFiles) {
|
| 955 |
+
const filePath = path.join(pathToChats, file);
|
| 956 |
+
const stats = await fs.promises.stat(filePath);
|
| 957 |
+
allChatFiles.push({ pngFile, filePath, mtime: stats.mtimeMs });
|
| 958 |
+
}
|
| 959 |
+
}
|
| 960 |
+
}
|
| 961 |
+
};
|
| 962 |
+
|
| 963 |
+
const getGroupChatFiles = async () => {
|
| 964 |
+
const groupDirents = await fs.promises.readdir(request.user.directories.groups, { withFileTypes: true });
|
| 965 |
+
const groups = groupDirents.filter(e => e.isFile() && path.extname(e.name) === '.json').map(e => e.name);
|
| 966 |
+
|
| 967 |
+
for (const group of groups) {
|
| 968 |
+
try {
|
| 969 |
+
const groupPath = path.join(request.user.directories.groups, group);
|
| 970 |
+
const groupContents = await fs.promises.readFile(groupPath, 'utf8');
|
| 971 |
+
const groupData = JSON.parse(groupContents);
|
| 972 |
+
|
| 973 |
+
if (Array.isArray(groupData.chats)) {
|
| 974 |
+
for (const chat of groupData.chats) {
|
| 975 |
+
const filePath = path.join(request.user.directories.groupChats, `${chat}.jsonl`);
|
| 976 |
+
if (!fs.existsSync(filePath)) {
|
| 977 |
+
continue;
|
| 978 |
+
}
|
| 979 |
+
const stats = await fs.promises.stat(filePath);
|
| 980 |
+
allChatFiles.push({ groupId: groupData.id, filePath, mtime: stats.mtimeMs });
|
| 981 |
+
}
|
| 982 |
+
}
|
| 983 |
+
} catch (error) {
|
| 984 |
+
// Skip group files that can't be read or parsed
|
| 985 |
+
continue;
|
| 986 |
+
}
|
| 987 |
+
}
|
| 988 |
+
};
|
| 989 |
+
|
| 990 |
+
const getRootChatFiles = async () => {
|
| 991 |
+
const dirents = await fs.promises.readdir(request.user.directories.chats, { withFileTypes: true });
|
| 992 |
+
const chatFiles = dirents.filter(e => e.isFile() && path.extname(e.name) === '.jsonl').map(e => e.name);
|
| 993 |
+
|
| 994 |
+
for (const file of chatFiles) {
|
| 995 |
+
const filePath = path.join(request.user.directories.chats, file);
|
| 996 |
+
const stats = await fs.promises.stat(filePath);
|
| 997 |
+
allChatFiles.push({ filePath, mtime: stats.mtimeMs });
|
| 998 |
+
}
|
| 999 |
+
};
|
| 1000 |
+
|
| 1001 |
+
await Promise.allSettled([getCharacterChatFiles(), getGroupChatFiles(), getRootChatFiles()]);
|
| 1002 |
+
|
| 1003 |
+
const max = parseInt(request.body.max ?? Number.MAX_SAFE_INTEGER);
|
| 1004 |
+
const recentChats = allChatFiles.sort((a, b) => b.mtime - a.mtime).slice(0, max);
|
| 1005 |
+
const jsonFilesPromise = recentChats.map((file) => {
|
| 1006 |
+
const withMetadata = !!request.body.metadata;
|
| 1007 |
+
return file.groupId
|
| 1008 |
+
? getChatInfo(file.filePath, { group: file.groupId }, withMetadata)
|
| 1009 |
+
: getChatInfo(file.filePath, { avatar: file.pngFile }, withMetadata);
|
| 1010 |
+
});
|
| 1011 |
+
|
| 1012 |
+
const chatData = (await Promise.allSettled(jsonFilesPromise)).filter(x => x.status === 'fulfilled').map(x => x.value);
|
| 1013 |
+
const validFiles = chatData.filter(i => i.file_name);
|
| 1014 |
+
|
| 1015 |
+
return response.send(validFiles);
|
| 1016 |
+
} catch (error) {
|
| 1017 |
+
console.error(error);
|
| 1018 |
+
return response.sendStatus(500);
|
| 1019 |
+
}
|
| 1020 |
+
});
|
src/endpoints/classify.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import express from 'express';
|
| 2 |
+
|
| 3 |
+
import { getPipeline } from '../transformers.js';
|
| 4 |
+
|
| 5 |
+
const TASK = 'text-classification';
|
| 6 |
+
|
| 7 |
+
export const router = express.Router();
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* @type {Map<string, object>} Cache for classification results
|
| 11 |
+
*/
|
| 12 |
+
const cacheObject = new Map();
|
| 13 |
+
|
| 14 |
+
router.post('/labels', async (req, res) => {
|
| 15 |
+
try {
|
| 16 |
+
const pipe = await getPipeline(TASK);
|
| 17 |
+
const result = Object.keys(pipe.model.config.label2id);
|
| 18 |
+
return res.json({ labels: result });
|
| 19 |
+
} catch (error) {
|
| 20 |
+
console.error(error);
|
| 21 |
+
return res.sendStatus(500);
|
| 22 |
+
}
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
router.post('/', async (req, res) => {
|
| 26 |
+
try {
|
| 27 |
+
const { text } = req.body;
|
| 28 |
+
|
| 29 |
+
/**
|
| 30 |
+
* Get classification result for a given text
|
| 31 |
+
* @param {string} text Text to classify
|
| 32 |
+
* @returns {Promise<object>} Classification result
|
| 33 |
+
*/
|
| 34 |
+
async function getResult(text) {
|
| 35 |
+
if (cacheObject.has(text)) {
|
| 36 |
+
return cacheObject.get(text);
|
| 37 |
+
} else {
|
| 38 |
+
const pipe = await getPipeline(TASK);
|
| 39 |
+
const result = await pipe(text, { topk: 5 });
|
| 40 |
+
result.sort((a, b) => b.score - a.score);
|
| 41 |
+
cacheObject.set(text, result);
|
| 42 |
+
return result;
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
console.debug('Classify input:', text);
|
| 47 |
+
const result = await getResult(text);
|
| 48 |
+
console.debug('Classify output:', result);
|
| 49 |
+
|
| 50 |
+
return res.json({ classification: result });
|
| 51 |
+
} catch (error) {
|
| 52 |
+
console.error(error);
|
| 53 |
+
return res.sendStatus(500);
|
| 54 |
+
}
|
| 55 |
+
});
|
src/endpoints/data-maid.js
ADDED
|
@@ -0,0 +1,816 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import crypto from 'node:crypto';
|
| 2 |
+
import fs from 'node:fs';
|
| 3 |
+
import path from 'node:path';
|
| 4 |
+
import express from 'express';
|
| 5 |
+
import mime from 'mime-types';
|
| 6 |
+
import { getSettingsBackupFilePrefix } from './settings.js';
|
| 7 |
+
import { CHAT_BACKUPS_PREFIX } from './chats.js';
|
| 8 |
+
import { isPathUnderParent, tryParse } from '../util.js';
|
| 9 |
+
import { SETTINGS_FILE } from '../constants.js';
|
| 10 |
+
|
| 11 |
+
const sha256 = str => crypto.createHash('sha256').update(str).digest('hex');
|
| 12 |
+
|
| 13 |
+
/**
|
| 14 |
+
* @typedef {object} DataMaidRawReport
|
| 15 |
+
* @property {string[]} images - List of loose user images
|
| 16 |
+
* @property {string[]} files - List of loose user files
|
| 17 |
+
* @property {string[]} chats - List of loose character chats
|
| 18 |
+
* @property {string[]} groupChats - List of loose group chats
|
| 19 |
+
* @property {string[]} avatarThumbnails - List of loose avatar thumbnails
|
| 20 |
+
* @property {string[]} backgroundThumbnails - List of loose background thumbnails
|
| 21 |
+
* @property {string[]} personaThumbnails - List of loose persona thumbnails
|
| 22 |
+
* @property {string[]} chatBackups - List of chat backups
|
| 23 |
+
* @property {string[]} settingsBackups - List of settings backups
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* @typedef {object} DataMaidSanitizedRecord - The entry excluding the sensitive paths.
|
| 28 |
+
* @property {string} name - The name of the file.
|
| 29 |
+
* @property {string} hash - The SHA-256 hash of the file path.
|
| 30 |
+
* @property {string} [parent] - The name of the parent directory, if applicable.
|
| 31 |
+
* @property {number} [size] - The size of the file in bytes, if available.
|
| 32 |
+
* @property {number} [mtime] - The last modification time of the file, if available.
|
| 33 |
+
*/
|
| 34 |
+
|
| 35 |
+
/**
|
| 36 |
+
* @typedef {object} DataMaidSanitizedReport - The report containing loose user data.
|
| 37 |
+
* @property {DataMaidSanitizedRecord[]} images - List of sanitized loose user images
|
| 38 |
+
* @property {DataMaidSanitizedRecord[]} files - List of sanitized loose user files
|
| 39 |
+
* @property {DataMaidSanitizedRecord[]} chats - List of sanitized loose character chats
|
| 40 |
+
* @property {DataMaidSanitizedRecord[]} groupChats - List of sanitized loose group chats
|
| 41 |
+
* @property {DataMaidSanitizedRecord[]} avatarThumbnails - List of sanitized loose avatar thumbnails
|
| 42 |
+
* @property {DataMaidSanitizedRecord[]} backgroundThumbnails - List of sanitized loose background thumbnails
|
| 43 |
+
* @property {DataMaidSanitizedRecord[]} personaThumbnails - List of sanitized loose persona thumbnails
|
| 44 |
+
* @property {DataMaidSanitizedRecord[]} chatBackups - List of sanitized chat backups
|
| 45 |
+
* @property {DataMaidSanitizedRecord[]} settingsBackups - List of sanitized settings backups
|
| 46 |
+
*/
|
| 47 |
+
|
| 48 |
+
/**
|
| 49 |
+
* @typedef {object} DataMaidMessage - The chat message object.
|
| 50 |
+
* @property {DataMaidMessageExtra} [extra] - The extra data object.
|
| 51 |
+
* @property {DataMaidChatMetadata} [chat_metadata] - The chat metadata object.
|
| 52 |
+
*/
|
| 53 |
+
|
| 54 |
+
/**
|
| 55 |
+
* @typedef {object} DataMaidFile - The file object.
|
| 56 |
+
* @property {string} url - The file URL
|
| 57 |
+
*/
|
| 58 |
+
|
| 59 |
+
/**
|
| 60 |
+
* @typedef {object} DataMaidMedia - The media object.
|
| 61 |
+
* @property {string} url - The media URL
|
| 62 |
+
*/
|
| 63 |
+
|
| 64 |
+
/**
|
| 65 |
+
* @typedef {object} DataMaidChatMetadata - The chat metadata object.
|
| 66 |
+
* @property {DataMaidFile[]} [attachments] - The array of attachments, if any.
|
| 67 |
+
* @property {string[]} [chat_backgrounds] - The array of chat background image links, if any.
|
| 68 |
+
*/
|
| 69 |
+
|
| 70 |
+
/**
|
| 71 |
+
* @typedef {object} DataMaidMessageExtra - The extra data object.
|
| 72 |
+
* @property {string} [image] - The link to the image, if any - DEPRECATED, use `media` instead.
|
| 73 |
+
* @property {string} [video] - The link to the video, if any - DEPRECATED, use `media` instead.
|
| 74 |
+
* @property {string[]} [image_swipes] - The links to the image swipes, if any - DEPRECATED, use `media` instead.
|
| 75 |
+
* @property {DataMaidMedia[]} [media] - The links to the media, if any.
|
| 76 |
+
* @property {DataMaidFile} [file] - The file object, if any - DEPRECATED, use `files` instead.
|
| 77 |
+
* @property {DataMaidFile[]} [files] - The array of file objects, if any.
|
| 78 |
+
*/
|
| 79 |
+
|
| 80 |
+
/**
|
| 81 |
+
* @typedef {object} DataMaidTokenEntry
|
| 82 |
+
* @property {string} handle - The user's handle or identifier.
|
| 83 |
+
* @property {{path: string, hash: string}[]} paths - The list of file paths and their hashes that can be cleaned up.
|
| 84 |
+
*/
|
| 85 |
+
|
| 86 |
+
/**
|
| 87 |
+
* Service for detecting and managing loose user data files.
|
| 88 |
+
* Helps identify orphaned files that are no longer referenced by the application.
|
| 89 |
+
*/
|
| 90 |
+
export class DataMaidService {
|
| 91 |
+
/**
|
| 92 |
+
* @type {Map<string, DataMaidTokenEntry>} Map clean-up tokens to user IDs
|
| 93 |
+
*/
|
| 94 |
+
static TOKENS = new Map();
|
| 95 |
+
|
| 96 |
+
/**
|
| 97 |
+
* Creates a new DataMaidService instance for a specific user.
|
| 98 |
+
* @param {string} handle - The user's handle.
|
| 99 |
+
* @param {import('../users.js').UserDirectoryList} directories - List of user directories to scan for loose data.
|
| 100 |
+
*/
|
| 101 |
+
constructor(handle, directories) {
|
| 102 |
+
this.handle = handle;
|
| 103 |
+
this.directories = directories;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
/**
|
| 107 |
+
* Generates a report of loose user data.
|
| 108 |
+
* @returns {Promise<DataMaidRawReport>} A report containing lists of loose user data.
|
| 109 |
+
*/
|
| 110 |
+
async generateReport() {
|
| 111 |
+
/** @type {DataMaidRawReport} */
|
| 112 |
+
const report = {
|
| 113 |
+
images: await this.#collectImages(),
|
| 114 |
+
files: await this.#collectFiles(),
|
| 115 |
+
chats: await this.#collectChats(),
|
| 116 |
+
groupChats: await this.#collectGroupChats(),
|
| 117 |
+
avatarThumbnails: await this.#collectAvatarThumbnails(),
|
| 118 |
+
backgroundThumbnails: await this.#collectBackgroundThumbnails(),
|
| 119 |
+
personaThumbnails: await this.#collectPersonaThumbnails(),
|
| 120 |
+
chatBackups: await this.#collectChatBackups(),
|
| 121 |
+
settingsBackups: await this.#collectSettingsBackups(),
|
| 122 |
+
};
|
| 123 |
+
|
| 124 |
+
return report;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
/**
|
| 129 |
+
* Sanitizes a record by hashing the file name and removing sensitive information.
|
| 130 |
+
* Additionally, adds metadata like size and modification time.
|
| 131 |
+
* @param {string} name The file or directory name to sanitize.
|
| 132 |
+
* @param {boolean} withParent If the model should include the parent directory name.
|
| 133 |
+
* @returns {Promise<DataMaidSanitizedRecord>} A sanitized record with the file name, hash, parent directory name, size, and modification time.
|
| 134 |
+
*/
|
| 135 |
+
async #sanitizeRecord(name, withParent) {
|
| 136 |
+
const stat = fs.existsSync(name) ? await fs.promises.stat(name) : null;
|
| 137 |
+
return {
|
| 138 |
+
name: path.basename(name),
|
| 139 |
+
hash: sha256(name),
|
| 140 |
+
parent: withParent ? path.basename(path.dirname(name)) : void 0,
|
| 141 |
+
size: stat?.size,
|
| 142 |
+
mtime: stat?.mtimeMs,
|
| 143 |
+
};
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
/**
|
| 147 |
+
* Sanitizes the report by hashing the file paths and removing sensitive information.
|
| 148 |
+
* @param {DataMaidRawReport} report - The raw report containing loose user data.
|
| 149 |
+
* @returns {Promise<DataMaidSanitizedReport>} A sanitized report with sensitive paths removed.
|
| 150 |
+
*/
|
| 151 |
+
async sanitizeReport(report) {
|
| 152 |
+
const sanitizedReport = {
|
| 153 |
+
images: await Promise.all(report.images.map(i => this.#sanitizeRecord(i, true))),
|
| 154 |
+
files: await Promise.all(report.files.map(i => this.#sanitizeRecord(i, false))),
|
| 155 |
+
chats: await Promise.all(report.chats.map(i => this.#sanitizeRecord(i, true))),
|
| 156 |
+
groupChats: await Promise.all(report.groupChats.map(i => this.#sanitizeRecord(i, false))),
|
| 157 |
+
avatarThumbnails: await Promise.all(report.avatarThumbnails.map(i => this.#sanitizeRecord(i, false))),
|
| 158 |
+
backgroundThumbnails: await Promise.all(report.backgroundThumbnails.map(i => this.#sanitizeRecord(i, false))),
|
| 159 |
+
personaThumbnails: await Promise.all(report.personaThumbnails.map(i => this.#sanitizeRecord(i, false))),
|
| 160 |
+
chatBackups: await Promise.all(report.chatBackups.map(i => this.#sanitizeRecord(i, false))),
|
| 161 |
+
settingsBackups: await Promise.all(report.settingsBackups.map(i => this.#sanitizeRecord(i, false))),
|
| 162 |
+
};
|
| 163 |
+
|
| 164 |
+
return sanitizedReport;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
/**
|
| 168 |
+
* Collects loose user images from the provided directories.
|
| 169 |
+
* Images are considered loose if they exist in the user images directory
|
| 170 |
+
* but are not referenced in any chat messages.
|
| 171 |
+
* @returns {Promise<string[]>} List of paths to loose user images
|
| 172 |
+
*/
|
| 173 |
+
async #collectImages() {
|
| 174 |
+
const result = [];
|
| 175 |
+
|
| 176 |
+
try {
|
| 177 |
+
const messages = await this.#parseAllChats(x => !!x?.extra?.image || !!x?.extra?.video || Array.isArray(x?.extra?.image_swipes) || Array.isArray(x?.extra?.media));
|
| 178 |
+
const knownImages = new Set();
|
| 179 |
+
for (const message of messages) {
|
| 180 |
+
if (message?.extra?.image) {
|
| 181 |
+
knownImages.add(message.extra.image);
|
| 182 |
+
}
|
| 183 |
+
if (message?.extra?.video) {
|
| 184 |
+
knownImages.add(message.extra.video);
|
| 185 |
+
}
|
| 186 |
+
if (Array.isArray(message?.extra?.image_swipes)) {
|
| 187 |
+
for (const swipe of message.extra.image_swipes) {
|
| 188 |
+
knownImages.add(swipe);
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
if (Array.isArray(message?.extra?.media)) {
|
| 192 |
+
for (const media of message.extra.media) {
|
| 193 |
+
if (media?.url) {
|
| 194 |
+
knownImages.add(media.url);
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
const metadata = await this.#parseAllMetadata(x => Array.isArray(x?.chat_backgrounds) && x.chat_backgrounds.length > 0);
|
| 200 |
+
for (const meta of metadata) {
|
| 201 |
+
if (Array.isArray(meta?.chat_backgrounds)) {
|
| 202 |
+
for (const background of meta.chat_backgrounds) {
|
| 203 |
+
if (background) {
|
| 204 |
+
knownImages.add(background);
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
}
|
| 209 |
+
const knownImageFullPaths = new Set();
|
| 210 |
+
knownImages.forEach(image => {
|
| 211 |
+
if (image.startsWith('http') || image.startsWith('data:')) {
|
| 212 |
+
return; // Skip URLs and data URIs
|
| 213 |
+
}
|
| 214 |
+
knownImageFullPaths.add(path.normalize(path.join(this.directories.root, image)));
|
| 215 |
+
});
|
| 216 |
+
const images = await fs.promises.readdir(this.directories.userImages, { withFileTypes: true });
|
| 217 |
+
for (const dirent of images) {
|
| 218 |
+
const direntPath = path.join(dirent.parentPath, dirent.name);
|
| 219 |
+
if (dirent.isFile() && !knownImageFullPaths.has(direntPath)) {
|
| 220 |
+
result.push(direntPath);
|
| 221 |
+
}
|
| 222 |
+
if (dirent.isDirectory()) {
|
| 223 |
+
const subdirFiles = await fs.promises.readdir(direntPath, { withFileTypes: true });
|
| 224 |
+
for (const file of subdirFiles) {
|
| 225 |
+
const subdirFilePath = path.join(direntPath, file.name);
|
| 226 |
+
if (file.isFile() && !knownImageFullPaths.has(subdirFilePath)) {
|
| 227 |
+
result.push(subdirFilePath);
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
}
|
| 231 |
+
}
|
| 232 |
+
} catch (error) {
|
| 233 |
+
console.error('[Data Maid] Error collecting user images:', error);
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
return result;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
/**
|
| 240 |
+
* Collects loose user files from the provided directories.
|
| 241 |
+
* Files are considered loose if they exist in the files directory
|
| 242 |
+
* but are not referenced in chat messages, metadata, or settings.
|
| 243 |
+
* @returns {Promise<string[]>} List of paths to loose user files
|
| 244 |
+
*/
|
| 245 |
+
async #collectFiles() {
|
| 246 |
+
const result = [];
|
| 247 |
+
|
| 248 |
+
try {
|
| 249 |
+
const messages = await this.#parseAllChats(x => !!x?.extra?.file?.url || (Array.isArray(x?.extra?.files) && x.extra.files.length > 0));
|
| 250 |
+
const knownFiles = new Set();
|
| 251 |
+
for (const message of messages) {
|
| 252 |
+
if (message?.extra?.file?.url) {
|
| 253 |
+
knownFiles.add(message.extra.file.url);
|
| 254 |
+
}
|
| 255 |
+
if (Array.isArray(message?.extra?.files)) {
|
| 256 |
+
for (const file of message.extra.files) {
|
| 257 |
+
if (file?.url) {
|
| 258 |
+
knownFiles.add(file.url);
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
}
|
| 262 |
+
}
|
| 263 |
+
const metadata = await this.#parseAllMetadata(x => Array.isArray(x?.attachments) && x.attachments.length > 0);
|
| 264 |
+
for (const meta of metadata) {
|
| 265 |
+
if (Array.isArray(meta?.attachments)) {
|
| 266 |
+
for (const attachment of meta.attachments) {
|
| 267 |
+
if (attachment?.url) {
|
| 268 |
+
knownFiles.add(attachment.url);
|
| 269 |
+
}
|
| 270 |
+
}
|
| 271 |
+
}
|
| 272 |
+
}
|
| 273 |
+
const pathToSettings = path.join(this.directories.root, SETTINGS_FILE);
|
| 274 |
+
if (fs.existsSync(pathToSettings)) {
|
| 275 |
+
try {
|
| 276 |
+
const settingsContent = await fs.promises.readFile(pathToSettings, 'utf-8');
|
| 277 |
+
const settings = tryParse(settingsContent);
|
| 278 |
+
if (Array.isArray(settings?.extension_settings?.attachments)) {
|
| 279 |
+
for (const file of settings.extension_settings.attachments) {
|
| 280 |
+
if (file?.url) {
|
| 281 |
+
knownFiles.add(file.url);
|
| 282 |
+
}
|
| 283 |
+
}
|
| 284 |
+
}
|
| 285 |
+
if (typeof settings?.extension_settings?.character_attachments === 'object') {
|
| 286 |
+
for (const files of Object.values(settings.extension_settings.character_attachments)) {
|
| 287 |
+
if (!Array.isArray(files)) {
|
| 288 |
+
continue;
|
| 289 |
+
}
|
| 290 |
+
for (const file of files) {
|
| 291 |
+
if (file?.url) {
|
| 292 |
+
knownFiles.add(file.url);
|
| 293 |
+
}
|
| 294 |
+
}
|
| 295 |
+
}
|
| 296 |
+
}
|
| 297 |
+
} catch (error) {
|
| 298 |
+
console.error('[Data Maid] Error reading settings file:', error);
|
| 299 |
+
}
|
| 300 |
+
}
|
| 301 |
+
const knownFileFullPaths = new Set();
|
| 302 |
+
knownFiles.forEach(file => {
|
| 303 |
+
knownFileFullPaths.add(path.normalize(path.join(this.directories.root, file)));
|
| 304 |
+
});
|
| 305 |
+
const files = await fs.promises.readdir(this.directories.files, { withFileTypes: true });
|
| 306 |
+
for (const file of files) {
|
| 307 |
+
const filePath = path.join(this.directories.files, file.name);
|
| 308 |
+
if (file.isFile() && !knownFileFullPaths.has(filePath)) {
|
| 309 |
+
result.push(filePath);
|
| 310 |
+
}
|
| 311 |
+
}
|
| 312 |
+
} catch (error) {
|
| 313 |
+
console.error('[Data Maid] Error collecting user files:', error);
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
return result;
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
/**
|
| 320 |
+
* Collects loose character chats from the provided directories.
|
| 321 |
+
* Chat folders are considered loose if they don't have corresponding character files.
|
| 322 |
+
* @returns {Promise<string[]>} List of paths to loose character chats
|
| 323 |
+
*/
|
| 324 |
+
async #collectChats() {
|
| 325 |
+
const result = [];
|
| 326 |
+
|
| 327 |
+
try {
|
| 328 |
+
const knownChatFolders = new Set();
|
| 329 |
+
const characters = await fs.promises.readdir(this.directories.characters, { withFileTypes: true });
|
| 330 |
+
for (const file of characters) {
|
| 331 |
+
if (file.isFile() && path.parse(file.name).ext === '.png') {
|
| 332 |
+
knownChatFolders.add(file.name.replace('.png', ''));
|
| 333 |
+
}
|
| 334 |
+
}
|
| 335 |
+
const chatFolders = await fs.promises.readdir(this.directories.chats, { withFileTypes: true });
|
| 336 |
+
for (const folder of chatFolders) {
|
| 337 |
+
if (folder.isDirectory() && !knownChatFolders.has(folder.name)) {
|
| 338 |
+
const chatFiles = await fs.promises.readdir(path.join(this.directories.chats, folder.name), { withFileTypes: true });
|
| 339 |
+
for (const file of chatFiles) {
|
| 340 |
+
if (file.isFile() && path.parse(file.name).ext === '.jsonl') {
|
| 341 |
+
result.push(path.join(this.directories.chats, folder.name, file.name));
|
| 342 |
+
}
|
| 343 |
+
}
|
| 344 |
+
}
|
| 345 |
+
}
|
| 346 |
+
} catch (error) {
|
| 347 |
+
console.error('[Data Maid] Error collecting character chats:', error);
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
return result;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
/**
|
| 354 |
+
* Collects loose group chats from the provided directories.
|
| 355 |
+
* Group chat files are considered loose if they're not referenced by any group definition.
|
| 356 |
+
* @returns {Promise<string[]>} List of paths to loose group chats
|
| 357 |
+
*/
|
| 358 |
+
async #collectGroupChats() {
|
| 359 |
+
const result = [];
|
| 360 |
+
|
| 361 |
+
try {
|
| 362 |
+
const groups = await fs.promises.readdir(this.directories.groups, { withFileTypes: true });
|
| 363 |
+
const knownGroupChats = new Set();
|
| 364 |
+
for (const file of groups) {
|
| 365 |
+
if (file.isFile() && path.parse(file.name).ext === '.json') {
|
| 366 |
+
try {
|
| 367 |
+
const pathToFile = path.join(this.directories.groups, file.name);
|
| 368 |
+
const fileContent = await fs.promises.readFile(pathToFile, 'utf-8');
|
| 369 |
+
const groupData = tryParse(fileContent);
|
| 370 |
+
if (groupData?.chat_id) {
|
| 371 |
+
knownGroupChats.add(groupData.chat_id);
|
| 372 |
+
}
|
| 373 |
+
if (Array.isArray(groupData?.chats)) {
|
| 374 |
+
for (const chat of groupData.chats) {
|
| 375 |
+
knownGroupChats.add(chat);
|
| 376 |
+
}
|
| 377 |
+
}
|
| 378 |
+
} catch (error) {
|
| 379 |
+
console.error(`[Data Maid] Error parsing group chat file ${file.name}:`, error);
|
| 380 |
+
}
|
| 381 |
+
}
|
| 382 |
+
}
|
| 383 |
+
const groupChats = await fs.promises.readdir(this.directories.groupChats, { withFileTypes: true });
|
| 384 |
+
for (const file of groupChats) {
|
| 385 |
+
if (file.isFile() && path.parse(file.name).ext === '.jsonl') {
|
| 386 |
+
if (!knownGroupChats.has(path.parse(file.name).name)) {
|
| 387 |
+
result.push(path.join(this.directories.groupChats, file.name));
|
| 388 |
+
}
|
| 389 |
+
}
|
| 390 |
+
}
|
| 391 |
+
} catch (error) {
|
| 392 |
+
console.error('[Data Maid] Error collecting group chats:', error);
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
return result;
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
/**
|
| 399 |
+
* Collects loose avatar thumbnails from the provided directories.
|
| 400 |
+
* @returns {Promise<string[]>} List of paths to loose avatar thumbnails
|
| 401 |
+
*/
|
| 402 |
+
async #collectAvatarThumbnails() {
|
| 403 |
+
const result = [];
|
| 404 |
+
|
| 405 |
+
try {
|
| 406 |
+
const knownAvatars = new Set();
|
| 407 |
+
const avatars = await fs.promises.readdir(this.directories.characters, { withFileTypes: true });
|
| 408 |
+
for (const file of avatars) {
|
| 409 |
+
if (file.isFile()) {
|
| 410 |
+
knownAvatars.add(file.name);
|
| 411 |
+
}
|
| 412 |
+
}
|
| 413 |
+
const avatarThumbnails = await fs.promises.readdir(this.directories.thumbnailsAvatar, { withFileTypes: true });
|
| 414 |
+
for (const file of avatarThumbnails) {
|
| 415 |
+
if (file.isFile() && !knownAvatars.has(file.name)) {
|
| 416 |
+
result.push(path.join(this.directories.thumbnailsAvatar, file.name));
|
| 417 |
+
}
|
| 418 |
+
}
|
| 419 |
+
} catch (error) {
|
| 420 |
+
console.error('[Data Maid] Error collecting avatar thumbnails:', error);
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
return result;
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
/**
|
| 427 |
+
* Collects loose background thumbnails from the provided directories.
|
| 428 |
+
* @returns {Promise<string[]>} List of paths to loose background thumbnails
|
| 429 |
+
*/
|
| 430 |
+
async #collectBackgroundThumbnails() {
|
| 431 |
+
const result = [];
|
| 432 |
+
|
| 433 |
+
try {
|
| 434 |
+
const knownBackgrounds = new Set();
|
| 435 |
+
const backgrounds = await fs.promises.readdir(this.directories.backgrounds, { withFileTypes: true });
|
| 436 |
+
for (const file of backgrounds) {
|
| 437 |
+
if (file.isFile()) {
|
| 438 |
+
knownBackgrounds.add(file.name);
|
| 439 |
+
}
|
| 440 |
+
}
|
| 441 |
+
const backgroundThumbnails = await fs.promises.readdir(this.directories.thumbnailsBg, { withFileTypes: true });
|
| 442 |
+
for (const file of backgroundThumbnails) {
|
| 443 |
+
if (file.isFile() && !knownBackgrounds.has(file.name)) {
|
| 444 |
+
result.push(path.join(this.directories.thumbnailsBg, file.name));
|
| 445 |
+
}
|
| 446 |
+
}
|
| 447 |
+
} catch (error) {
|
| 448 |
+
console.error('[Data Maid] Error collecting background thumbnails:', error);
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
return result;
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
/**
|
| 455 |
+
* Collects loose persona thumbnails from the provided directories.
|
| 456 |
+
* @returns {Promise<string[]>} List of paths to loose persona thumbnails
|
| 457 |
+
*/
|
| 458 |
+
async #collectPersonaThumbnails() {
|
| 459 |
+
const result = [];
|
| 460 |
+
|
| 461 |
+
try {
|
| 462 |
+
const knownPersonas = new Set();
|
| 463 |
+
const personas = await fs.promises.readdir(this.directories.avatars, { withFileTypes: true });
|
| 464 |
+
for (const file of personas) {
|
| 465 |
+
if (file.isFile()) {
|
| 466 |
+
knownPersonas.add(file.name);
|
| 467 |
+
}
|
| 468 |
+
}
|
| 469 |
+
const personaThumbnails = await fs.promises.readdir(this.directories.thumbnailsPersona, { withFileTypes: true });
|
| 470 |
+
for (const file of personaThumbnails) {
|
| 471 |
+
if (file.isFile() && !knownPersonas.has(file.name)) {
|
| 472 |
+
result.push(path.join(this.directories.thumbnailsPersona, file.name));
|
| 473 |
+
}
|
| 474 |
+
}
|
| 475 |
+
} catch (error) {
|
| 476 |
+
console.error('[Data Maid] Error collecting persona thumbnails:', error);
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
return result;
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
/**
|
| 483 |
+
* Collects chat backups from the provided directories.
|
| 484 |
+
* @returns {Promise<string[]>} List of paths to chat backups
|
| 485 |
+
*/
|
| 486 |
+
async #collectChatBackups() {
|
| 487 |
+
const result = [];
|
| 488 |
+
|
| 489 |
+
try {
|
| 490 |
+
const prefix = CHAT_BACKUPS_PREFIX;
|
| 491 |
+
const backups = await fs.promises.readdir(this.directories.backups, { withFileTypes: true });
|
| 492 |
+
for (const file of backups) {
|
| 493 |
+
if (file.isFile() && file.name.startsWith(prefix)) {
|
| 494 |
+
result.push(path.join(this.directories.backups, file.name));
|
| 495 |
+
}
|
| 496 |
+
}
|
| 497 |
+
} catch (error) {
|
| 498 |
+
console.error('[Data Maid] Error collecting chat backups:', error);
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
return result;
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
/**
|
| 505 |
+
* Collects settings backups from the provided directories.
|
| 506 |
+
* @returns {Promise<string[]>} List of paths to settings backups
|
| 507 |
+
*/
|
| 508 |
+
async #collectSettingsBackups() {
|
| 509 |
+
const result = [];
|
| 510 |
+
|
| 511 |
+
try {
|
| 512 |
+
const prefix = getSettingsBackupFilePrefix(this.handle);
|
| 513 |
+
const backups = await fs.promises.readdir(this.directories.backups, { withFileTypes: true });
|
| 514 |
+
for (const file of backups) {
|
| 515 |
+
if (file.isFile() && file.name.startsWith(prefix)) {
|
| 516 |
+
result.push(path.join(this.directories.backups, file.name));
|
| 517 |
+
}
|
| 518 |
+
}
|
| 519 |
+
} catch (error) {
|
| 520 |
+
console.error('[Data Maid] Error collecting settings backups:', error);
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
return result;
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
/**
|
| 527 |
+
* Parses all chat files and returns an array of chat messages.
|
| 528 |
+
* Searches both individual character chats and group chats.
|
| 529 |
+
* @param {function(DataMaidMessage): boolean} filterFn - Filter function to apply to each message.
|
| 530 |
+
* @returns {Promise<DataMaidMessage[]>} Array of chat messages
|
| 531 |
+
*/
|
| 532 |
+
async #parseAllChats(filterFn) {
|
| 533 |
+
try {
|
| 534 |
+
const allChats = [];
|
| 535 |
+
|
| 536 |
+
const groupChats = await fs.promises.readdir(this.directories.groupChats, { withFileTypes: true });
|
| 537 |
+
for (const file of groupChats) {
|
| 538 |
+
if (file.isFile() && path.parse(file.name).ext === '.jsonl') {
|
| 539 |
+
const chatMessages = await this.#parseChatFile(path.join(this.directories.groupChats, file.name));
|
| 540 |
+
allChats.push(...chatMessages.filter(filterFn));
|
| 541 |
+
}
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
const chatDirectories = await fs.promises.readdir(this.directories.chats, { withFileTypes: true });
|
| 545 |
+
for (const directory of chatDirectories) {
|
| 546 |
+
if (directory.isDirectory()) {
|
| 547 |
+
const chatFiles = await fs.promises.readdir(path.join(this.directories.chats, directory.name), { withFileTypes: true });
|
| 548 |
+
for (const file of chatFiles) {
|
| 549 |
+
if (file.isFile() && path.parse(file.name).ext === '.jsonl') {
|
| 550 |
+
const chatMessages = await this.#parseChatFile(path.join(this.directories.chats, directory.name, file.name));
|
| 551 |
+
allChats.push(...chatMessages.filter(filterFn));
|
| 552 |
+
}
|
| 553 |
+
}
|
| 554 |
+
}
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
return allChats;
|
| 558 |
+
} catch (error) {
|
| 559 |
+
console.error('[Data Maid] Error parsing chats:', error);
|
| 560 |
+
return [];
|
| 561 |
+
}
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
/**
|
| 565 |
+
* Parses all metadata from chat files and group definitions.
|
| 566 |
+
* Extracts metadata from both active and historical chat data.
|
| 567 |
+
* @param {function(DataMaidChatMetadata): boolean} filterFn - Filter function to apply to each metadata entry.
|
| 568 |
+
* @returns {Promise<DataMaidChatMetadata[]>} Parsed chat metadata as an array.
|
| 569 |
+
*/
|
| 570 |
+
async #parseAllMetadata(filterFn) {
|
| 571 |
+
try {
|
| 572 |
+
const allMetadata = [];
|
| 573 |
+
|
| 574 |
+
const groups = await fs.promises.readdir(this.directories.groups, { withFileTypes: true });
|
| 575 |
+
for (const file of groups) {
|
| 576 |
+
if (file.isFile() && path.parse(file.name).ext === '.json') {
|
| 577 |
+
try {
|
| 578 |
+
const pathToFile = path.join(this.directories.groups, file.name);
|
| 579 |
+
const fileContent = await fs.promises.readFile(pathToFile, 'utf-8');
|
| 580 |
+
const groupData = tryParse(fileContent);
|
| 581 |
+
if (groupData?.chat_metadata && filterFn(groupData.chat_metadata)) {
|
| 582 |
+
console.warn('Found group chat metadata in group definition - this is deprecated behavior.');
|
| 583 |
+
allMetadata.push(groupData.chat_metadata);
|
| 584 |
+
}
|
| 585 |
+
if (groupData?.past_metadata) {
|
| 586 |
+
console.warn('Found group past chat metadata in group definition - this is deprecated behavior.');
|
| 587 |
+
allMetadata.push(...Object.values(groupData.past_metadata).filter(filterFn));
|
| 588 |
+
}
|
| 589 |
+
} catch (error) {
|
| 590 |
+
console.error(`[Data Maid] Error parsing group chat file ${file.name}:`, error);
|
| 591 |
+
}
|
| 592 |
+
}
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
const groupChats = await fs.promises.readdir(this.directories.groupChats, { withFileTypes: true });
|
| 596 |
+
for (const file of groupChats) {
|
| 597 |
+
if (file.isFile() && path.parse(file.name).ext === '.jsonl') {
|
| 598 |
+
const chatMessages = await this.#parseChatFile(path.join(this.directories.groupChats, file.name));
|
| 599 |
+
const chatMetadata = chatMessages?.[0]?.chat_metadata;
|
| 600 |
+
if (chatMetadata && filterFn(chatMetadata)) {
|
| 601 |
+
allMetadata.push(chatMetadata);
|
| 602 |
+
}
|
| 603 |
+
}
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
const chatDirectories = await fs.promises.readdir(this.directories.chats, { withFileTypes: true });
|
| 607 |
+
for (const directory of chatDirectories) {
|
| 608 |
+
if (directory.isDirectory()) {
|
| 609 |
+
const chatFiles = await fs.promises.readdir(path.join(this.directories.chats, directory.name), { withFileTypes: true });
|
| 610 |
+
for (const file of chatFiles) {
|
| 611 |
+
if (file.isFile() && path.parse(file.name).ext === '.jsonl') {
|
| 612 |
+
const chatMessages = await this.#parseChatFile(path.join(this.directories.chats, directory.name, file.name));
|
| 613 |
+
const chatMetadata = chatMessages?.[0]?.chat_metadata;
|
| 614 |
+
if (chatMetadata && filterFn(chatMetadata)) {
|
| 615 |
+
allMetadata.push(chatMetadata);
|
| 616 |
+
}
|
| 617 |
+
}
|
| 618 |
+
}
|
| 619 |
+
}
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
return allMetadata;
|
| 623 |
+
} catch (error) {
|
| 624 |
+
console.error('[Data Maid] Error parsing chats:', error);
|
| 625 |
+
return [];
|
| 626 |
+
}
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
/**
|
| 630 |
+
* Parses a single chat file and returns an array of chat messages.
|
| 631 |
+
* Each line in the JSONL file represents one message.
|
| 632 |
+
* @param {string} filePath Path to the chat file to parse.
|
| 633 |
+
* @returns {Promise<DataMaidMessage[]>} Parsed chat messages as an array.
|
| 634 |
+
*/
|
| 635 |
+
async #parseChatFile(filePath) {
|
| 636 |
+
try {
|
| 637 |
+
const content = await fs.promises.readFile(filePath, 'utf-8');
|
| 638 |
+
const chatData = content.split('\n').map(tryParse).filter(Boolean);
|
| 639 |
+
return chatData;
|
| 640 |
+
} catch (error) {
|
| 641 |
+
console.error(`[Data Maid] Error reading chat file ${filePath}:`, error);
|
| 642 |
+
return [];
|
| 643 |
+
}
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
/**
|
| 647 |
+
* Generates a unique token for the user to clean up their data.
|
| 648 |
+
* Replaces any existing token for the same user.
|
| 649 |
+
* @param {string} handle - The user's handle or identifier.
|
| 650 |
+
* @param {DataMaidRawReport} report - The report containing loose user data.
|
| 651 |
+
* @returns {string} A unique token.
|
| 652 |
+
*/
|
| 653 |
+
static generateToken(handle, report) {
|
| 654 |
+
// Remove any existing token for this user
|
| 655 |
+
for (const [token, entry] of this.TOKENS.entries()) {
|
| 656 |
+
if (entry.handle === handle) {
|
| 657 |
+
this.TOKENS.delete(token);
|
| 658 |
+
}
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
const token = crypto.randomBytes(32).toString('hex');
|
| 662 |
+
const tokenEntry = {
|
| 663 |
+
handle,
|
| 664 |
+
paths: Object.values(report).filter(v => Array.isArray(v)).flat().map(x => ({ path: x, hash: sha256(x) })),
|
| 665 |
+
};
|
| 666 |
+
this.TOKENS.set(token, tokenEntry);
|
| 667 |
+
return token;
|
| 668 |
+
}
|
| 669 |
+
}
|
| 670 |
+
|
| 671 |
+
export const router = express.Router();
|
| 672 |
+
|
| 673 |
+
router.post('/report', async (req, res) => {
|
| 674 |
+
try {
|
| 675 |
+
if (!req.user || !req.user.directories) {
|
| 676 |
+
return res.sendStatus(403);
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
const dataMaid = new DataMaidService(req.user.profile.handle, req.user.directories);
|
| 680 |
+
const rawReport = await dataMaid.generateReport();
|
| 681 |
+
|
| 682 |
+
const report = await dataMaid.sanitizeReport(rawReport);
|
| 683 |
+
const token = DataMaidService.generateToken(req.user.profile.handle, rawReport);
|
| 684 |
+
|
| 685 |
+
return res.json({ report, token });
|
| 686 |
+
} catch (error) {
|
| 687 |
+
console.error('[Data Maid] Error generating data maid report:', error);
|
| 688 |
+
return res.sendStatus(500);
|
| 689 |
+
}
|
| 690 |
+
});
|
| 691 |
+
|
| 692 |
+
router.post('/finalize', async (req, res) => {
|
| 693 |
+
try {
|
| 694 |
+
if (!req.user || !req.user.directories) {
|
| 695 |
+
return res.sendStatus(403);
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
if (!req.body.token) {
|
| 699 |
+
return res.sendStatus(400);
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
const token = req.body.token.toString();
|
| 703 |
+
if (!DataMaidService.TOKENS.has(token)) {
|
| 704 |
+
return res.sendStatus(403);
|
| 705 |
+
}
|
| 706 |
+
|
| 707 |
+
const tokenEntry = DataMaidService.TOKENS.get(token);
|
| 708 |
+
if (!tokenEntry || tokenEntry.handle !== req.user.profile.handle) {
|
| 709 |
+
return res.sendStatus(403);
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
// Remove the token after finalization
|
| 713 |
+
DataMaidService.TOKENS.delete(token);
|
| 714 |
+
return res.sendStatus(204);
|
| 715 |
+
} catch (error) {
|
| 716 |
+
console.error('[Data Maid] Error finalizing the token:', error);
|
| 717 |
+
return res.sendStatus(500);
|
| 718 |
+
}
|
| 719 |
+
});
|
| 720 |
+
|
| 721 |
+
router.get('/view', async (req, res) => {
|
| 722 |
+
try {
|
| 723 |
+
if (!req.user || !req.user.directories) {
|
| 724 |
+
return res.sendStatus(403);
|
| 725 |
+
}
|
| 726 |
+
|
| 727 |
+
if (!req.query.token || !req.query.hash) {
|
| 728 |
+
return res.sendStatus(400);
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
const token = req.query.token.toString();
|
| 732 |
+
const hash = req.query.hash.toString();
|
| 733 |
+
|
| 734 |
+
if (!DataMaidService.TOKENS.has(token)) {
|
| 735 |
+
return res.sendStatus(403);
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
const tokenEntry = DataMaidService.TOKENS.get(token);
|
| 739 |
+
if (!tokenEntry || tokenEntry.handle !== req.user.profile.handle) {
|
| 740 |
+
return res.sendStatus(403);
|
| 741 |
+
}
|
| 742 |
+
|
| 743 |
+
const fileEntry = tokenEntry.paths.find(entry => entry.hash === hash);
|
| 744 |
+
if (!fileEntry) {
|
| 745 |
+
return res.sendStatus(404);
|
| 746 |
+
}
|
| 747 |
+
|
| 748 |
+
if (!isPathUnderParent(req.user.directories.root, fileEntry.path)) {
|
| 749 |
+
console.warn('[Data Maid] Attempted access to a file outside of the user directory:', fileEntry.path);
|
| 750 |
+
return res.sendStatus(403);
|
| 751 |
+
}
|
| 752 |
+
|
| 753 |
+
const pathToFile = fileEntry.path;
|
| 754 |
+
const fileExists = fs.existsSync(pathToFile);
|
| 755 |
+
|
| 756 |
+
if (!fileExists) {
|
| 757 |
+
return res.sendStatus(404);
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
const fileBuffer = await fs.promises.readFile(pathToFile);
|
| 761 |
+
const mimeType = mime.lookup(pathToFile) || 'text/plain';
|
| 762 |
+
res.setHeader('Content-Type', mimeType);
|
| 763 |
+
return res.send(fileBuffer);
|
| 764 |
+
} catch (error) {
|
| 765 |
+
console.error('[Data Maid] Error viewing file:', error);
|
| 766 |
+
return res.sendStatus(500);
|
| 767 |
+
}
|
| 768 |
+
});
|
| 769 |
+
|
| 770 |
+
router.post('/delete', async (req, res) => {
|
| 771 |
+
try {
|
| 772 |
+
if (!req.user || !req.user.directories) {
|
| 773 |
+
return res.sendStatus(403);
|
| 774 |
+
}
|
| 775 |
+
|
| 776 |
+
const { token, hashes } = req.body;
|
| 777 |
+
if (!token || !Array.isArray(hashes) || hashes.length === 0) {
|
| 778 |
+
return res.sendStatus(400);
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
if (!DataMaidService.TOKENS.has(token)) {
|
| 782 |
+
return res.sendStatus(403);
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
const tokenEntry = DataMaidService.TOKENS.get(token);
|
| 786 |
+
if (!tokenEntry || tokenEntry.handle !== req.user.profile.handle) {
|
| 787 |
+
return res.sendStatus(403);
|
| 788 |
+
}
|
| 789 |
+
|
| 790 |
+
for (const hash of hashes) {
|
| 791 |
+
const fileEntry = tokenEntry.paths.find(entry => entry.hash === hash);
|
| 792 |
+
if (!fileEntry) {
|
| 793 |
+
continue;
|
| 794 |
+
}
|
| 795 |
+
|
| 796 |
+
if (!isPathUnderParent(req.user.directories.root, fileEntry.path)) {
|
| 797 |
+
console.warn('[Data Maid] Attempted deletion of a file outside of the user directory:', fileEntry.path);
|
| 798 |
+
continue;
|
| 799 |
+
}
|
| 800 |
+
|
| 801 |
+
const pathToFile = fileEntry.path;
|
| 802 |
+
const fileExists = fs.existsSync(pathToFile);
|
| 803 |
+
|
| 804 |
+
if (!fileExists) {
|
| 805 |
+
continue;
|
| 806 |
+
}
|
| 807 |
+
|
| 808 |
+
await fs.promises.unlink(pathToFile);
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
return res.sendStatus(204);
|
| 812 |
+
} catch (error) {
|
| 813 |
+
console.error('[Data Maid] Error deleting files:', error);
|
| 814 |
+
return res.sendStatus(500);
|
| 815 |
+
}
|
| 816 |
+
});
|
src/endpoints/extensions.js
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import path from 'node:path';
|
| 2 |
+
import fs from 'node:fs';
|
| 3 |
+
|
| 4 |
+
import express from 'express';
|
| 5 |
+
import sanitize from 'sanitize-filename';
|
| 6 |
+
import { CheckRepoActions, default as simpleGit } from 'simple-git';
|
| 7 |
+
|
| 8 |
+
import { PUBLIC_DIRECTORIES } from '../constants.js';
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* @type {Partial<import('simple-git').SimpleGitOptions>}
|
| 12 |
+
*/
|
| 13 |
+
const OPTIONS = Object.freeze({ timeout: { block: 5 * 60 * 1000 } });
|
| 14 |
+
|
| 15 |
+
/**
|
| 16 |
+
* This function extracts the extension information from the manifest file.
|
| 17 |
+
* @param {string} extensionPath - The path of the extension folder
|
| 18 |
+
* @returns {Promise<Object>} - Returns the manifest data as an object
|
| 19 |
+
*/
|
| 20 |
+
async function getManifest(extensionPath) {
|
| 21 |
+
const manifestPath = path.join(extensionPath, 'manifest.json');
|
| 22 |
+
|
| 23 |
+
// Check if manifest.json exists
|
| 24 |
+
if (!fs.existsSync(manifestPath)) {
|
| 25 |
+
throw new Error(`Manifest file not found at ${manifestPath}`);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
| 29 |
+
return manifest;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* This function checks if the local repository is up-to-date with the remote repository.
|
| 34 |
+
* @param {string} extensionPath - The path of the extension folder
|
| 35 |
+
* @returns {Promise<Object>} - Returns the extension information as an object
|
| 36 |
+
*/
|
| 37 |
+
async function checkIfRepoIsUpToDate(extensionPath) {
|
| 38 |
+
const git = simpleGit({ baseDir: extensionPath, ...OPTIONS });
|
| 39 |
+
await git.fetch('origin');
|
| 40 |
+
const currentBranch = await git.branch();
|
| 41 |
+
const currentCommitHash = await git.revparse(['HEAD']);
|
| 42 |
+
const log = await git.log({
|
| 43 |
+
from: currentCommitHash,
|
| 44 |
+
to: `origin/${currentBranch.current}`,
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
// Fetch remote repository information
|
| 48 |
+
const remotes = await git.getRemotes(true);
|
| 49 |
+
if (remotes.length === 0) {
|
| 50 |
+
return {
|
| 51 |
+
isUpToDate: true,
|
| 52 |
+
remoteUrl: '',
|
| 53 |
+
};
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
return {
|
| 57 |
+
isUpToDate: log.total === 0,
|
| 58 |
+
remoteUrl: remotes[0].refs.fetch, // URL of the remote repository
|
| 59 |
+
};
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
export const router = express.Router();
|
| 63 |
+
|
| 64 |
+
/**
|
| 65 |
+
* HTTP POST handler function to clone a git repository from a provided URL, read the extension manifest,
|
| 66 |
+
* and return extension information and path.
|
| 67 |
+
*
|
| 68 |
+
* @param {Object} request - HTTP Request object, expects a JSON body with a 'url' property.
|
| 69 |
+
* @param {Object} response - HTTP Response object used to respond to the HTTP request.
|
| 70 |
+
*
|
| 71 |
+
* @returns {void}
|
| 72 |
+
*/
|
| 73 |
+
router.post('/install', async (request, response) => {
|
| 74 |
+
if (!request.body.url) {
|
| 75 |
+
return response.status(400).send('Bad Request: URL is required in the request body.');
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
try {
|
| 79 |
+
// No timeout for cloning, as it may take a while depending on the repo size
|
| 80 |
+
const git = simpleGit();
|
| 81 |
+
|
| 82 |
+
// make sure the third-party directory exists
|
| 83 |
+
if (!fs.existsSync(path.join(request.user.directories.extensions))) {
|
| 84 |
+
fs.mkdirSync(path.join(request.user.directories.extensions));
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
if (!fs.existsSync(PUBLIC_DIRECTORIES.globalExtensions)) {
|
| 88 |
+
fs.mkdirSync(PUBLIC_DIRECTORIES.globalExtensions);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
const { url, global, branch } = request.body;
|
| 92 |
+
|
| 93 |
+
if (global && !request.user.profile.admin) {
|
| 94 |
+
console.error(`User ${request.user.profile.handle} does not have permission to install global extensions.`);
|
| 95 |
+
return response.status(403).send('Forbidden: No permission to install global extensions.');
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
|
| 99 |
+
const extensionPath = path.join(basePath, sanitize(path.basename(url, '.git')));
|
| 100 |
+
|
| 101 |
+
if (fs.existsSync(extensionPath)) {
|
| 102 |
+
return response.status(409).send(`Directory already exists at ${extensionPath}`);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
const cloneOptions = { '--depth': 1 };
|
| 106 |
+
if (branch) {
|
| 107 |
+
cloneOptions['--branch'] = branch;
|
| 108 |
+
}
|
| 109 |
+
await git.clone(url, extensionPath, cloneOptions);
|
| 110 |
+
console.info(`Extension has been cloned to ${extensionPath} from ${url} at ${branch || '(default)'} branch`);
|
| 111 |
+
|
| 112 |
+
const { version, author, display_name } = await getManifest(extensionPath);
|
| 113 |
+
|
| 114 |
+
return response.send({ version, author, display_name, extensionPath });
|
| 115 |
+
} catch (error) {
|
| 116 |
+
console.error('Importing custom content failed', error);
|
| 117 |
+
return response.status(500).send(`Server Error: ${error.message}`);
|
| 118 |
+
}
|
| 119 |
+
});
|
| 120 |
+
|
| 121 |
+
/**
|
| 122 |
+
* HTTP POST handler function to pull the latest updates from a git repository
|
| 123 |
+
* based on the extension name provided in the request body. It returns the latest commit hash,
|
| 124 |
+
* the path of the extension, the status of the repository (whether it's up-to-date or not),
|
| 125 |
+
* and the remote URL of the repository.
|
| 126 |
+
*
|
| 127 |
+
* @param {Object} request - HTTP Request object, expects a JSON body with an 'extensionName' property.
|
| 128 |
+
* @param {Object} response - HTTP Response object used to respond to the HTTP request.
|
| 129 |
+
*
|
| 130 |
+
* @returns {void}
|
| 131 |
+
*/
|
| 132 |
+
router.post('/update', async (request, response) => {
|
| 133 |
+
if (!request.body.extensionName) {
|
| 134 |
+
return response.status(400).send('Bad Request: extensionName is required in the request body.');
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
try {
|
| 138 |
+
const { extensionName, global } = request.body;
|
| 139 |
+
|
| 140 |
+
if (global && !request.user.profile.admin) {
|
| 141 |
+
console.error(`User ${request.user.profile.handle} does not have permission to update global extensions.`);
|
| 142 |
+
return response.status(403).send('Forbidden: No permission to update global extensions.');
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
|
| 146 |
+
const extensionPath = path.join(basePath, sanitize(extensionName));
|
| 147 |
+
|
| 148 |
+
if (!fs.existsSync(extensionPath)) {
|
| 149 |
+
return response.status(404).send(`Directory does not exist at ${extensionPath}`);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
|
| 153 |
+
const git = simpleGit({ baseDir: extensionPath, ...OPTIONS });
|
| 154 |
+
const isRepo = await git.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
|
| 155 |
+
if (!isRepo) {
|
| 156 |
+
throw new Error(`Directory is not a Git repository at ${extensionPath}`);
|
| 157 |
+
}
|
| 158 |
+
const currentBranch = await git.branch();
|
| 159 |
+
if (!isUpToDate) {
|
| 160 |
+
await git.pull('origin', currentBranch.current);
|
| 161 |
+
console.info(`Extension has been updated at ${extensionPath}`);
|
| 162 |
+
} else {
|
| 163 |
+
console.info(`Extension is up to date at ${extensionPath}`);
|
| 164 |
+
}
|
| 165 |
+
await git.fetch('origin');
|
| 166 |
+
const fullCommitHash = await git.revparse(['HEAD']);
|
| 167 |
+
const shortCommitHash = fullCommitHash.slice(0, 7);
|
| 168 |
+
|
| 169 |
+
return response.send({ shortCommitHash, extensionPath, isUpToDate, remoteUrl });
|
| 170 |
+
} catch (error) {
|
| 171 |
+
console.error('Updating extension failed', error);
|
| 172 |
+
return response.status(500).send('Internal Server Error. Check the server logs for more details.');
|
| 173 |
+
}
|
| 174 |
+
});
|
| 175 |
+
|
| 176 |
+
router.post('/branches', async (request, response) => {
|
| 177 |
+
try {
|
| 178 |
+
const { extensionName, global } = request.body;
|
| 179 |
+
|
| 180 |
+
if (!extensionName) {
|
| 181 |
+
return response.status(400).send('Bad Request: extensionName is required in the request body.');
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
if (global && !request.user.profile.admin) {
|
| 185 |
+
console.error(`User ${request.user.profile.handle} does not have permission to list branches of global extensions.`);
|
| 186 |
+
return response.status(403).send('Forbidden: No permission to list branches of global extensions.');
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
|
| 190 |
+
const extensionPath = path.join(basePath, sanitize(extensionName));
|
| 191 |
+
|
| 192 |
+
if (!fs.existsSync(extensionPath)) {
|
| 193 |
+
return response.status(404).send(`Directory does not exist at ${extensionPath}`);
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
const git = simpleGit({ baseDir: extensionPath, ...OPTIONS });
|
| 197 |
+
// Unshallow the repository if it is shallow
|
| 198 |
+
const isShallow = await git.revparse(['--is-shallow-repository']) === 'true';
|
| 199 |
+
if (isShallow) {
|
| 200 |
+
console.info(`Unshallowing the repository at ${extensionPath}`);
|
| 201 |
+
await git.fetch('origin', ['--unshallow']);
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
// Fetch all branches
|
| 205 |
+
await git.remote(['set-branches', 'origin', '*']);
|
| 206 |
+
await git.fetch('origin');
|
| 207 |
+
const localBranches = await git.branchLocal();
|
| 208 |
+
const remoteBranches = await git.branch(['-r', '--list', 'origin/*']);
|
| 209 |
+
const result = [
|
| 210 |
+
...Object.values(localBranches.branches),
|
| 211 |
+
...Object.values(remoteBranches.branches),
|
| 212 |
+
].map(b => ({ current: b.current, commit: b.commit, name: b.name, label: b.label }));
|
| 213 |
+
|
| 214 |
+
return response.send(result);
|
| 215 |
+
} catch (error) {
|
| 216 |
+
console.error('Getting branches failed', error);
|
| 217 |
+
return response.status(500).send('Internal Server Error. Check the server logs for more details.');
|
| 218 |
+
}
|
| 219 |
+
});
|
| 220 |
+
|
| 221 |
+
router.post('/switch', async (request, response) => {
|
| 222 |
+
try {
|
| 223 |
+
const { extensionName, branch, global } = request.body;
|
| 224 |
+
|
| 225 |
+
if (!extensionName || !branch) {
|
| 226 |
+
return response.status(400).send('Bad Request: extensionName and branch are required in the request body.');
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
if (global && !request.user.profile.admin) {
|
| 230 |
+
console.error(`User ${request.user.profile.handle} does not have permission to switch branches of global extensions.`);
|
| 231 |
+
return response.status(403).send('Forbidden: No permission to switch branches of global extensions.');
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
|
| 235 |
+
const extensionPath = path.join(basePath, sanitize(extensionName));
|
| 236 |
+
|
| 237 |
+
if (!fs.existsSync(extensionPath)) {
|
| 238 |
+
return response.status(404).send(`Directory does not exist at ${extensionPath}`);
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
const git = simpleGit({ baseDir: extensionPath, ...OPTIONS });
|
| 242 |
+
const branches = await git.branchLocal();
|
| 243 |
+
|
| 244 |
+
if (String(branch).startsWith('origin/')) {
|
| 245 |
+
const localBranch = branch.replace('origin/', '');
|
| 246 |
+
if (branches.all.includes(localBranch)) {
|
| 247 |
+
console.info(`Branch ${localBranch} already exists locally, checking it out`);
|
| 248 |
+
await git.checkout(localBranch);
|
| 249 |
+
return response.sendStatus(204);
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
console.info(`Branch ${localBranch} does not exist locally, creating it from ${branch}`);
|
| 253 |
+
await git.checkoutBranch(localBranch, branch);
|
| 254 |
+
return response.sendStatus(204);
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
if (!branches.all.includes(branch)) {
|
| 258 |
+
console.error(`Branch ${branch} does not exist locally`);
|
| 259 |
+
return response.status(404).send(`Branch ${branch} does not exist locally`);
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
// Check if the branch is already checked out
|
| 263 |
+
const currentBranch = await git.branch();
|
| 264 |
+
if (currentBranch.current === branch) {
|
| 265 |
+
console.info(`Branch ${branch} is already checked out`);
|
| 266 |
+
return response.sendStatus(204);
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
// Checkout the branch
|
| 270 |
+
await git.checkout(branch);
|
| 271 |
+
console.info(`Checked out branch ${branch} at ${extensionPath}`);
|
| 272 |
+
|
| 273 |
+
return response.sendStatus(204);
|
| 274 |
+
} catch (error) {
|
| 275 |
+
console.error('Switching branches failed', error);
|
| 276 |
+
return response.status(500).send('Internal Server Error. Check the server logs for more details.');
|
| 277 |
+
}
|
| 278 |
+
});
|
| 279 |
+
|
| 280 |
+
router.post('/move', async (request, response) => {
|
| 281 |
+
try {
|
| 282 |
+
const { extensionName, source, destination } = request.body;
|
| 283 |
+
|
| 284 |
+
if (!extensionName || !source || !destination) {
|
| 285 |
+
return response.status(400).send('Bad Request. Not all required parameters are provided.');
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
if (!request.user.profile.admin) {
|
| 289 |
+
console.error(`User ${request.user.profile.handle} does not have permission to move extensions.`);
|
| 290 |
+
return response.status(403).send('Forbidden: No permission to move extensions.');
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
const sourceDirectory = source === 'global' ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
|
| 294 |
+
const destinationDirectory = destination === 'global' ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
|
| 295 |
+
const sourcePath = path.join(sourceDirectory, sanitize(extensionName));
|
| 296 |
+
const destinationPath = path.join(destinationDirectory, sanitize(extensionName));
|
| 297 |
+
|
| 298 |
+
if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isDirectory()) {
|
| 299 |
+
console.error(`Source directory does not exist at ${sourcePath}`);
|
| 300 |
+
return response.status(404).send('Source directory does not exist.');
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
if (fs.existsSync(destinationPath)) {
|
| 304 |
+
console.error(`Destination directory already exists at ${destinationPath}`);
|
| 305 |
+
return response.status(409).send('Destination directory already exists.');
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
if (source === destination) {
|
| 309 |
+
console.error('Source and destination directories are the same');
|
| 310 |
+
return response.status(409).send('Source and destination directories are the same.');
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
fs.cpSync(sourcePath, destinationPath, { recursive: true, force: true });
|
| 314 |
+
fs.rmSync(sourcePath, { recursive: true, force: true });
|
| 315 |
+
console.info(`Extension has been moved from ${sourcePath} to ${destinationPath}`);
|
| 316 |
+
|
| 317 |
+
return response.sendStatus(204);
|
| 318 |
+
} catch (error) {
|
| 319 |
+
console.error('Moving extension failed', error);
|
| 320 |
+
return response.status(500).send('Internal Server Error. Check the server logs for more details.');
|
| 321 |
+
}
|
| 322 |
+
});
|
| 323 |
+
|
| 324 |
+
/**
|
| 325 |
+
* HTTP POST handler function to get the current git commit hash and branch name for a given extension.
|
| 326 |
+
* It checks whether the repository is up-to-date with the remote, and returns the status along with
|
| 327 |
+
* the remote URL of the repository.
|
| 328 |
+
*
|
| 329 |
+
* @param {Object} request - HTTP Request object, expects a JSON body with an 'extensionName' property.
|
| 330 |
+
* @param {Object} response - HTTP Response object used to respond to the HTTP request.
|
| 331 |
+
*
|
| 332 |
+
* @returns {void}
|
| 333 |
+
*/
|
| 334 |
+
router.post('/version', async (request, response) => {
|
| 335 |
+
if (!request.body.extensionName) {
|
| 336 |
+
return response.status(400).send('Bad Request: extensionName is required in the request body.');
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
try {
|
| 340 |
+
const { extensionName, global } = request.body;
|
| 341 |
+
const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
|
| 342 |
+
const extensionPath = path.join(basePath, sanitize(extensionName));
|
| 343 |
+
|
| 344 |
+
if (!fs.existsSync(extensionPath)) {
|
| 345 |
+
return response.status(404).send(`Directory does not exist at ${extensionPath}`);
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
const git = simpleGit({ baseDir: extensionPath, ...OPTIONS });
|
| 349 |
+
let currentCommitHash;
|
| 350 |
+
try {
|
| 351 |
+
const isRepo = await git.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
|
| 352 |
+
if (!isRepo) {
|
| 353 |
+
throw new Error(`Directory is not a Git repository at ${extensionPath}`);
|
| 354 |
+
}
|
| 355 |
+
currentCommitHash = await git.revparse(['HEAD']);
|
| 356 |
+
} catch (error) {
|
| 357 |
+
// it is not a git repo, or has no commits yet, or is a bare repo
|
| 358 |
+
// not possible to update it, most likely can't get the branch name either
|
| 359 |
+
return response.send({ currentBranchName: '', currentCommitHash: '', isUpToDate: true, remoteUrl: '' });
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
const currentBranch = await git.branch();
|
| 363 |
+
// get only the working branch
|
| 364 |
+
const currentBranchName = currentBranch.current;
|
| 365 |
+
await git.fetch('origin');
|
| 366 |
+
console.debug(extensionName, currentBranchName, currentCommitHash);
|
| 367 |
+
const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
|
| 368 |
+
|
| 369 |
+
return response.send({ currentBranchName, currentCommitHash, isUpToDate, remoteUrl });
|
| 370 |
+
|
| 371 |
+
} catch (error) {
|
| 372 |
+
console.error('Getting extension version failed', error);
|
| 373 |
+
return response.status(500).send(`Server Error: ${error.message}`);
|
| 374 |
+
}
|
| 375 |
+
});
|
| 376 |
+
|
| 377 |
+
/**
|
| 378 |
+
* HTTP POST handler function to delete a git repository based on the extension name provided in the request body.
|
| 379 |
+
*
|
| 380 |
+
* @param {Object} request - HTTP Request object, expects a JSON body with a 'url' property.
|
| 381 |
+
* @param {Object} response - HTTP Response object used to respond to the HTTP request.
|
| 382 |
+
*
|
| 383 |
+
* @returns {void}
|
| 384 |
+
*/
|
| 385 |
+
router.post('/delete', async (request, response) => {
|
| 386 |
+
if (!request.body.extensionName) {
|
| 387 |
+
return response.status(400).send('Bad Request: extensionName is required in the request body.');
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
try {
|
| 391 |
+
const { extensionName, global } = request.body;
|
| 392 |
+
|
| 393 |
+
if (global && !request.user.profile.admin) {
|
| 394 |
+
console.error(`User ${request.user.profile.handle} does not have permission to delete global extensions.`);
|
| 395 |
+
return response.status(403).send('Forbidden: No permission to delete global extensions.');
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
|
| 399 |
+
const extensionPath = path.join(basePath, sanitize(extensionName));
|
| 400 |
+
|
| 401 |
+
if (!fs.existsSync(extensionPath)) {
|
| 402 |
+
return response.status(404).send(`Directory does not exist at ${extensionPath}`);
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
await fs.promises.rm(extensionPath, { recursive: true });
|
| 406 |
+
console.info(`Extension has been deleted at ${extensionPath}`);
|
| 407 |
+
|
| 408 |
+
return response.send(`Extension has been deleted at ${extensionPath}`);
|
| 409 |
+
|
| 410 |
+
} catch (error) {
|
| 411 |
+
console.error('Deleting custom content failed', error);
|
| 412 |
+
return response.status(500).send(`Server Error: ${error.message}`);
|
| 413 |
+
}
|
| 414 |
+
});
|
| 415 |
+
|
| 416 |
+
/**
|
| 417 |
+
* Discover the extension folders
|
| 418 |
+
* If the folder is called third-party, search for subfolders instead
|
| 419 |
+
*/
|
| 420 |
+
router.get('/discover', function (request, response) {
|
| 421 |
+
if (!fs.existsSync(path.join(request.user.directories.extensions))) {
|
| 422 |
+
fs.mkdirSync(path.join(request.user.directories.extensions));
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
if (!fs.existsSync(PUBLIC_DIRECTORIES.globalExtensions)) {
|
| 426 |
+
fs.mkdirSync(PUBLIC_DIRECTORIES.globalExtensions);
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
// Get all folders in system extensions folder, excluding third-party
|
| 430 |
+
const builtInExtensions = fs
|
| 431 |
+
.readdirSync(PUBLIC_DIRECTORIES.extensions)
|
| 432 |
+
.filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.extensions, f)).isDirectory())
|
| 433 |
+
.filter(f => f !== 'third-party')
|
| 434 |
+
.map(f => ({ type: 'system', name: f }));
|
| 435 |
+
|
| 436 |
+
// Get all folders in local extensions folder
|
| 437 |
+
const userExtensions = fs
|
| 438 |
+
.readdirSync(path.join(request.user.directories.extensions))
|
| 439 |
+
.filter(f => fs.statSync(path.join(request.user.directories.extensions, f)).isDirectory())
|
| 440 |
+
.map(f => ({ type: 'local', name: `third-party/${f}` }));
|
| 441 |
+
|
| 442 |
+
// Get all folders in global extensions folder
|
| 443 |
+
// In case of a conflict, the extension will be loaded from the user folder
|
| 444 |
+
const globalExtensions = fs
|
| 445 |
+
.readdirSync(PUBLIC_DIRECTORIES.globalExtensions)
|
| 446 |
+
.filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.globalExtensions, f)).isDirectory())
|
| 447 |
+
.map(f => ({ type: 'global', name: `third-party/${f}` }))
|
| 448 |
+
.filter(f => !userExtensions.some(e => e.name === f.name));
|
| 449 |
+
|
| 450 |
+
// Combine all extensions
|
| 451 |
+
const allExtensions = [...builtInExtensions, ...userExtensions, ...globalExtensions];
|
| 452 |
+
console.debug('Extensions available for', request.user.profile.handle, allExtensions);
|
| 453 |
+
|
| 454 |
+
return response.send(allExtensions);
|
| 455 |
+
});
|
src/endpoints/files.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import path from 'node:path';
|
| 2 |
+
import fs from 'node:fs';
|
| 3 |
+
|
| 4 |
+
import express from 'express';
|
| 5 |
+
import sanitize from 'sanitize-filename';
|
| 6 |
+
import { sync as writeFileSyncAtomic } from 'write-file-atomic';
|
| 7 |
+
|
| 8 |
+
import { validateAssetFileName } from './assets.js';
|
| 9 |
+
import { clientRelativePath } from '../util.js';
|
| 10 |
+
|
| 11 |
+
export const router = express.Router();
|
| 12 |
+
|
| 13 |
+
router.post('/sanitize-filename', async (request, response) => {
|
| 14 |
+
try {
|
| 15 |
+
const fileName = String(request.body.fileName);
|
| 16 |
+
if (!fileName) {
|
| 17 |
+
return response.status(400).send('No fileName specified');
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const sanitizedFilename = sanitize(fileName);
|
| 21 |
+
return response.send({ fileName: sanitizedFilename });
|
| 22 |
+
} catch (error) {
|
| 23 |
+
console.error(error);
|
| 24 |
+
return response.sendStatus(500);
|
| 25 |
+
}
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
router.post('/upload', async (request, response) => {
|
| 29 |
+
try {
|
| 30 |
+
if (!request.body.name) {
|
| 31 |
+
return response.status(400).send('No upload name specified');
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
if (!request.body.data) {
|
| 35 |
+
return response.status(400).send('No upload data specified');
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
// Validate filename
|
| 39 |
+
const validation = validateAssetFileName(request.body.name);
|
| 40 |
+
if (validation.error)
|
| 41 |
+
return response.status(400).send(validation.message);
|
| 42 |
+
|
| 43 |
+
const pathToUpload = path.join(request.user.directories.files, request.body.name);
|
| 44 |
+
writeFileSyncAtomic(pathToUpload, request.body.data, 'base64');
|
| 45 |
+
const url = clientRelativePath(request.user.directories.root, pathToUpload);
|
| 46 |
+
console.info(`Uploaded file: ${url} from ${request.user.profile.handle}`);
|
| 47 |
+
return response.send({ path: url });
|
| 48 |
+
} catch (error) {
|
| 49 |
+
console.error(error);
|
| 50 |
+
return response.sendStatus(500);
|
| 51 |
+
}
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
router.post('/delete', async (request, response) => {
|
| 55 |
+
try {
|
| 56 |
+
if (!request.body.path) {
|
| 57 |
+
return response.status(400).send('No path specified');
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
const pathToDelete = path.join(request.user.directories.root, request.body.path);
|
| 61 |
+
if (!pathToDelete.startsWith(request.user.directories.files)) {
|
| 62 |
+
return response.status(400).send('Invalid path');
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
if (!fs.existsSync(pathToDelete)) {
|
| 66 |
+
return response.status(404).send('File not found');
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
fs.unlinkSync(pathToDelete);
|
| 70 |
+
console.info(`Deleted file: ${request.body.path} from ${request.user.profile.handle}`);
|
| 71 |
+
return response.sendStatus(200);
|
| 72 |
+
} catch (error) {
|
| 73 |
+
console.error(error);
|
| 74 |
+
return response.sendStatus(500);
|
| 75 |
+
}
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
router.post('/verify', async (request, response) => {
|
| 79 |
+
try {
|
| 80 |
+
if (!Array.isArray(request.body.urls)) {
|
| 81 |
+
return response.status(400).send('No URLs specified');
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
const verified = {};
|
| 85 |
+
|
| 86 |
+
for (const url of request.body.urls) {
|
| 87 |
+
const pathToVerify = path.join(request.user.directories.root, url);
|
| 88 |
+
if (!pathToVerify.startsWith(request.user.directories.files)) {
|
| 89 |
+
console.warn(`File verification: Invalid path: ${pathToVerify}`);
|
| 90 |
+
continue;
|
| 91 |
+
}
|
| 92 |
+
const fileExists = fs.existsSync(pathToVerify);
|
| 93 |
+
verified[url] = fileExists;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
return response.send(verified);
|
| 97 |
+
} catch (error) {
|
| 98 |
+
console.error(error);
|
| 99 |
+
return response.sendStatus(500);
|
| 100 |
+
}
|
| 101 |
+
});
|
src/endpoints/google.js
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Buffer } from 'node:buffer';
|
| 2 |
+
import fetch from 'node-fetch';
|
| 3 |
+
import express from 'express';
|
| 4 |
+
import { speak, languages } from 'google-translate-api-x';
|
| 5 |
+
import crypto from 'node:crypto';
|
| 6 |
+
import util from 'node:util';
|
| 7 |
+
import urlJoin from 'url-join';
|
| 8 |
+
import lodash from 'lodash';
|
| 9 |
+
|
| 10 |
+
import { readSecret, SECRET_KEYS } from './secrets.js';
|
| 11 |
+
import { GEMINI_SAFETY, VERTEX_SAFETY } from '../constants.js';
|
| 12 |
+
import { delay, getConfigValue, trimTrailingSlash } from '../util.js';
|
| 13 |
+
|
| 14 |
+
const API_MAKERSUITE = 'https://generativelanguage.googleapis.com';
|
| 15 |
+
const API_VERTEX_AI = 'https://us-central1-aiplatform.googleapis.com';
|
| 16 |
+
|
| 17 |
+
function createWavHeader(dataSize, sampleRate, numChannels = 1, bitsPerSample = 16) {
|
| 18 |
+
const header = Buffer.alloc(44);
|
| 19 |
+
header.write('RIFF', 0);
|
| 20 |
+
header.writeUInt32LE(36 + dataSize, 4);
|
| 21 |
+
header.write('WAVE', 8);
|
| 22 |
+
header.write('fmt ', 12);
|
| 23 |
+
header.writeUInt32LE(16, 16);
|
| 24 |
+
header.writeUInt16LE(1, 20);
|
| 25 |
+
header.writeUInt16LE(numChannels, 22);
|
| 26 |
+
header.writeUInt32LE(sampleRate, 24);
|
| 27 |
+
header.writeUInt32LE(sampleRate * numChannels * bitsPerSample / 8, 28);
|
| 28 |
+
header.writeUInt16LE(numChannels * bitsPerSample / 8, 32);
|
| 29 |
+
header.writeUInt16LE(bitsPerSample, 34);
|
| 30 |
+
header.write('data', 36);
|
| 31 |
+
header.writeUInt32LE(dataSize, 40);
|
| 32 |
+
return header;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
function createCompleteWavFile(pcmData, sampleRate) {
|
| 36 |
+
const header = createWavHeader(pcmData.length, sampleRate);
|
| 37 |
+
return Buffer.concat([header, pcmData]);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// Vertex AI authentication helper functions
|
| 41 |
+
export async function getVertexAIAuth(request) {
|
| 42 |
+
const authMode = request.body.vertexai_auth_mode || 'express';
|
| 43 |
+
|
| 44 |
+
if (request.body.reverse_proxy) {
|
| 45 |
+
return {
|
| 46 |
+
authHeader: `Bearer ${request.body.proxy_password}`,
|
| 47 |
+
authType: 'proxy',
|
| 48 |
+
};
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
if (authMode === 'express') {
|
| 52 |
+
const apiKey = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI);
|
| 53 |
+
if (apiKey) {
|
| 54 |
+
return {
|
| 55 |
+
authHeader: `Bearer ${apiKey}`,
|
| 56 |
+
authType: 'express',
|
| 57 |
+
};
|
| 58 |
+
}
|
| 59 |
+
throw new Error('API key is required for Vertex AI Express mode');
|
| 60 |
+
} else if (authMode === 'full') {
|
| 61 |
+
// Get service account JSON from backend storage
|
| 62 |
+
const serviceAccountJson = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT);
|
| 63 |
+
|
| 64 |
+
if (serviceAccountJson) {
|
| 65 |
+
try {
|
| 66 |
+
const serviceAccount = JSON.parse(serviceAccountJson);
|
| 67 |
+
const jwtToken = await generateJWTToken(serviceAccount);
|
| 68 |
+
const accessToken = await getAccessToken(jwtToken);
|
| 69 |
+
return {
|
| 70 |
+
authHeader: `Bearer ${accessToken}`,
|
| 71 |
+
authType: 'full',
|
| 72 |
+
};
|
| 73 |
+
} catch (error) {
|
| 74 |
+
console.error('Failed to authenticate with service account:', error);
|
| 75 |
+
throw new Error(`Service account authentication failed: ${error.message}`);
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
throw new Error('Service Account JSON is required for Vertex AI Full mode');
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
throw new Error(`Unsupported Vertex AI authentication mode: ${authMode}`);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/**
|
| 85 |
+
* Generates a JWT token for Google Cloud authentication using service account credentials.
|
| 86 |
+
* @param {object} serviceAccount Service account JSON object
|
| 87 |
+
* @returns {Promise<string>} JWT token
|
| 88 |
+
*/
|
| 89 |
+
export async function generateJWTToken(serviceAccount) {
|
| 90 |
+
const now = Math.floor(Date.now() / 1000);
|
| 91 |
+
const expiry = now + 3600; // 1 hour
|
| 92 |
+
|
| 93 |
+
const header = {
|
| 94 |
+
alg: 'RS256',
|
| 95 |
+
typ: 'JWT',
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
const payload = {
|
| 99 |
+
iss: serviceAccount.client_email,
|
| 100 |
+
scope: 'https://www.googleapis.com/auth/cloud-platform',
|
| 101 |
+
aud: 'https://oauth2.googleapis.com/token',
|
| 102 |
+
iat: now,
|
| 103 |
+
exp: expiry,
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
const headerBase64 = Buffer.from(JSON.stringify(header)).toString('base64url');
|
| 107 |
+
const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
| 108 |
+
const signatureInput = `${headerBase64}.${payloadBase64}`;
|
| 109 |
+
|
| 110 |
+
// Create signature using private key
|
| 111 |
+
const sign = crypto.createSign('RSA-SHA256');
|
| 112 |
+
sign.update(signatureInput);
|
| 113 |
+
const signature = sign.sign(serviceAccount.private_key, 'base64url');
|
| 114 |
+
|
| 115 |
+
return `${signatureInput}.${signature}`;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
export async function getAccessToken(jwtToken) {
|
| 119 |
+
const response = await fetch('https://oauth2.googleapis.com/token', {
|
| 120 |
+
method: 'POST',
|
| 121 |
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
| 122 |
+
body: new URLSearchParams({
|
| 123 |
+
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
| 124 |
+
assertion: jwtToken,
|
| 125 |
+
}),
|
| 126 |
+
});
|
| 127 |
+
|
| 128 |
+
if (!response.ok) {
|
| 129 |
+
const error = await response.text();
|
| 130 |
+
throw new Error(`Failed to get access token: ${error}`);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
/** @type {any} */
|
| 134 |
+
const data = await response.json();
|
| 135 |
+
return data.access_token;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
/**
|
| 139 |
+
* Extracts the project ID from a Service Account JSON object.
|
| 140 |
+
* @param {object} serviceAccount Service account JSON object
|
| 141 |
+
* @returns {string} Project ID
|
| 142 |
+
* @throws {Error} If project ID is not found in the service account
|
| 143 |
+
*/
|
| 144 |
+
export function getProjectIdFromServiceAccount(serviceAccount) {
|
| 145 |
+
if (!serviceAccount || typeof serviceAccount !== 'object') {
|
| 146 |
+
throw new Error('Invalid service account object');
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
const projectId = serviceAccount.project_id;
|
| 150 |
+
if (!projectId || typeof projectId !== 'string') {
|
| 151 |
+
throw new Error('Project ID not found in service account JSON');
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
return projectId;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
/**
|
| 158 |
+
* Generates Google API URL and headers based on request configuration
|
| 159 |
+
* @param {express.Request} request Express request object
|
| 160 |
+
* @param {string} model Model name to use
|
| 161 |
+
* @param {string} endpoint API endpoint (default: 'generateContent')
|
| 162 |
+
* @returns {Promise<{url: string, headers: object, apiName: string, baseUrl: string, safetySettings: object[]}>} URL, headers, and API name
|
| 163 |
+
*/
|
| 164 |
+
export async function getGoogleApiConfig(request, model, endpoint = 'generateContent') {
|
| 165 |
+
const useVertexAi = request.body.api === 'vertexai';
|
| 166 |
+
const region = request.body.vertexai_region || 'us-central1';
|
| 167 |
+
const apiName = useVertexAi ? 'Google Vertex AI' : 'Google AI Studio';
|
| 168 |
+
const safetySettings = [...GEMINI_SAFETY, ...(useVertexAi ? VERTEX_SAFETY : [])];
|
| 169 |
+
|
| 170 |
+
let url;
|
| 171 |
+
let baseUrl;
|
| 172 |
+
let headers = {
|
| 173 |
+
'Content-Type': 'application/json',
|
| 174 |
+
};
|
| 175 |
+
|
| 176 |
+
if (useVertexAi) {
|
| 177 |
+
// Get authentication for Vertex AI
|
| 178 |
+
const { authHeader, authType } = await getVertexAIAuth(request);
|
| 179 |
+
|
| 180 |
+
if (authType === 'express') {
|
| 181 |
+
// Express mode: use API key parameter
|
| 182 |
+
const keyParam = authHeader.replace('Bearer ', '');
|
| 183 |
+
const projectId = request.body.vertexai_express_project_id;
|
| 184 |
+
baseUrl = region === 'global'
|
| 185 |
+
? 'https://aiplatform.googleapis.com/v1'
|
| 186 |
+
: `https://${region}-aiplatform.googleapis.com/v1`;
|
| 187 |
+
url = projectId
|
| 188 |
+
? `${baseUrl}/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${endpoint}`
|
| 189 |
+
: `${baseUrl}/publishers/google/models/${model}:${endpoint}`;
|
| 190 |
+
headers['x-goog-api-key'] = keyParam;
|
| 191 |
+
} else if (authType === 'full') {
|
| 192 |
+
// Full mode: use project-specific URL with Authorization header
|
| 193 |
+
// Get project ID from Service Account JSON
|
| 194 |
+
const serviceAccountJson = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT);
|
| 195 |
+
if (!serviceAccountJson) {
|
| 196 |
+
throw new Error('Vertex AI Service Account JSON is missing.');
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
let projectId;
|
| 200 |
+
try {
|
| 201 |
+
const serviceAccount = JSON.parse(serviceAccountJson);
|
| 202 |
+
projectId = getProjectIdFromServiceAccount(serviceAccount);
|
| 203 |
+
} catch (error) {
|
| 204 |
+
throw new Error('Failed to extract project ID from Service Account JSON.');
|
| 205 |
+
}
|
| 206 |
+
// Handle global region differently - no region prefix in hostname
|
| 207 |
+
baseUrl = region === 'global'
|
| 208 |
+
? 'https://aiplatform.googleapis.com/v1'
|
| 209 |
+
: `https://${region}-aiplatform.googleapis.com/v1`;
|
| 210 |
+
url = `${baseUrl}/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${endpoint}`;
|
| 211 |
+
headers['Authorization'] = authHeader;
|
| 212 |
+
} else {
|
| 213 |
+
// Proxy mode: use Authorization header
|
| 214 |
+
const apiUrl = trimTrailingSlash(request.body.reverse_proxy || API_VERTEX_AI);
|
| 215 |
+
baseUrl = `${apiUrl}/v1`;
|
| 216 |
+
url = `${baseUrl}/publishers/google/models/${model}:${endpoint}`;
|
| 217 |
+
headers['Authorization'] = authHeader;
|
| 218 |
+
}
|
| 219 |
+
} else {
|
| 220 |
+
// Google AI Studio
|
| 221 |
+
const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE);
|
| 222 |
+
const apiUrl = trimTrailingSlash(request.body.reverse_proxy || API_MAKERSUITE);
|
| 223 |
+
const apiVersion = getConfigValue('gemini.apiVersion', 'v1beta');
|
| 224 |
+
baseUrl = `${apiUrl}/${apiVersion}`;
|
| 225 |
+
url = `${baseUrl}/models/${model}:${endpoint}`;
|
| 226 |
+
headers['x-goog-api-key'] = apiKey;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
return { url, headers, apiName, baseUrl, safetySettings };
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
export const router = express.Router();
|
| 233 |
+
|
| 234 |
+
router.post('/caption-image', async (request, response) => {
|
| 235 |
+
try {
|
| 236 |
+
const mimeType = request.body.image.split(';')[0].split(':')[1];
|
| 237 |
+
const base64Data = request.body.image.split(',')[1];
|
| 238 |
+
const model = request.body.model || 'gemini-2.0-flash';
|
| 239 |
+
const { url, headers, apiName, safetySettings } = await getGoogleApiConfig(request, model);
|
| 240 |
+
|
| 241 |
+
const body = {
|
| 242 |
+
contents: [{
|
| 243 |
+
role: 'user',
|
| 244 |
+
parts: [
|
| 245 |
+
{ text: request.body.prompt },
|
| 246 |
+
{
|
| 247 |
+
inlineData: {
|
| 248 |
+
mimeType: mimeType,
|
| 249 |
+
data: base64Data,
|
| 250 |
+
},
|
| 251 |
+
}],
|
| 252 |
+
}],
|
| 253 |
+
safetySettings: safetySettings,
|
| 254 |
+
};
|
| 255 |
+
|
| 256 |
+
console.debug(`${apiName} captioning request`, model, body);
|
| 257 |
+
|
| 258 |
+
const result = await fetch(url, {
|
| 259 |
+
body: JSON.stringify(body),
|
| 260 |
+
method: 'POST',
|
| 261 |
+
headers: headers,
|
| 262 |
+
});
|
| 263 |
+
|
| 264 |
+
if (!result.ok) {
|
| 265 |
+
const error = await result.json();
|
| 266 |
+
console.error(`${apiName} API returned error: ${result.status} ${result.statusText}`, error);
|
| 267 |
+
return response.status(500).send({ error: true });
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
/** @type {any} */
|
| 271 |
+
const data = await result.json();
|
| 272 |
+
console.info(`${apiName} captioning response`, data);
|
| 273 |
+
|
| 274 |
+
const candidates = data?.candidates;
|
| 275 |
+
if (!candidates) {
|
| 276 |
+
return response.status(500).send('No candidates found, image was most likely filtered.');
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
const caption = candidates[0].content.parts[0].text;
|
| 280 |
+
if (!caption) {
|
| 281 |
+
return response.status(500).send('No caption found');
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
return response.json({ caption });
|
| 285 |
+
} catch (error) {
|
| 286 |
+
console.error(error);
|
| 287 |
+
response.status(500).send('Internal server error');
|
| 288 |
+
}
|
| 289 |
+
});
|
| 290 |
+
|
| 291 |
+
router.post('/list-voices', (_, response) => {
|
| 292 |
+
return response.json(languages);
|
| 293 |
+
});
|
| 294 |
+
|
| 295 |
+
router.post('/generate-voice', async (request, response) => {
|
| 296 |
+
try {
|
| 297 |
+
const text = request.body.text;
|
| 298 |
+
const voice = request.body.voice ?? 'en';
|
| 299 |
+
|
| 300 |
+
const result = await speak(text, { to: voice, forceBatch: false });
|
| 301 |
+
const buffer = Array.isArray(result)
|
| 302 |
+
? Buffer.concat(result.map(x => new Uint8Array(Buffer.from(x.toString(), 'base64'))))
|
| 303 |
+
: Buffer.from(result.toString(), 'base64');
|
| 304 |
+
|
| 305 |
+
response.setHeader('Content-Type', 'audio/mpeg');
|
| 306 |
+
return response.send(buffer);
|
| 307 |
+
} catch (error) {
|
| 308 |
+
console.error('Google Translate TTS generation failed', error);
|
| 309 |
+
response.status(500).send('Internal server error');
|
| 310 |
+
}
|
| 311 |
+
});
|
| 312 |
+
|
| 313 |
+
router.post('/list-native-voices', async (_, response) => {
|
| 314 |
+
try {
|
| 315 |
+
// Hardcoded Gemini native TTS voices from official documentation
|
| 316 |
+
// Source: https://ai.google.dev/gemini-api/docs/speech-generation#voices
|
| 317 |
+
const voices = [
|
| 318 |
+
{ name: 'Zephyr', voice_id: 'Zephyr', lang: 'en-US', description: 'Bright' },
|
| 319 |
+
{ name: 'Puck', voice_id: 'Puck', lang: 'en-US', description: 'Upbeat' },
|
| 320 |
+
{ name: 'Charon', voice_id: 'Charon', lang: 'en-US', description: 'Informative' },
|
| 321 |
+
{ name: 'Kore', voice_id: 'Kore', lang: 'en-US', description: 'Firm' },
|
| 322 |
+
{ name: 'Fenrir', voice_id: 'Fenrir', lang: 'en-US', description: 'Excitable' },
|
| 323 |
+
{ name: 'Leda', voice_id: 'Leda', lang: 'en-US', description: 'Youthful' },
|
| 324 |
+
{ name: 'Orus', voice_id: 'Orus', lang: 'en-US', description: 'Firm' },
|
| 325 |
+
{ name: 'Aoede', voice_id: 'Aoede', lang: 'en-US', description: 'Breezy' },
|
| 326 |
+
{ name: 'Callirhoe', voice_id: 'Callirhoe', lang: 'en-US', description: 'Easy-going' },
|
| 327 |
+
{ name: 'Autonoe', voice_id: 'Autonoe', lang: 'en-US', description: 'Bright' },
|
| 328 |
+
{ name: 'Enceladus', voice_id: 'Enceladus', lang: 'en-US', description: 'Breathy' },
|
| 329 |
+
{ name: 'Iapetus', voice_id: 'Iapetus', lang: 'en-US', description: 'Clear' },
|
| 330 |
+
{ name: 'Umbriel', voice_id: 'Umbriel', lang: 'en-US', description: 'Easy-going' },
|
| 331 |
+
{ name: 'Algieba', voice_id: 'Algieba', lang: 'en-US', description: 'Smooth' },
|
| 332 |
+
{ name: 'Despina', voice_id: 'Despina', lang: 'en-US', description: 'Smooth' },
|
| 333 |
+
{ name: 'Erinome', voice_id: 'Erinome', lang: 'en-US', description: 'Clear' },
|
| 334 |
+
{ name: 'Algenib', voice_id: 'Algenib', lang: 'en-US', description: 'Gravelly' },
|
| 335 |
+
{ name: 'Rasalgethi', voice_id: 'Rasalgethi', lang: 'en-US', description: 'Informative' },
|
| 336 |
+
{ name: 'Laomedeia', voice_id: 'Laomedeia', lang: 'en-US', description: 'Upbeat' },
|
| 337 |
+
{ name: 'Achernar', voice_id: 'Achernar', lang: 'en-US', description: 'Soft' },
|
| 338 |
+
{ name: 'Alnilam', voice_id: 'Alnilam', lang: 'en-US', description: 'Firm' },
|
| 339 |
+
{ name: 'Schedar', voice_id: 'Schedar', lang: 'en-US', description: 'Even' },
|
| 340 |
+
{ name: 'Gacrux', voice_id: 'Gacrux', lang: 'en-US', description: 'Mature' },
|
| 341 |
+
{ name: 'Pulcherrima', voice_id: 'Pulcherrima', lang: 'en-US', description: 'Forward' },
|
| 342 |
+
{ name: 'Achird', voice_id: 'Achird', lang: 'en-US', description: 'Friendly' },
|
| 343 |
+
{ name: 'Zubenelgenubi', voice_id: 'Zubenelgenubi', lang: 'en-US', description: 'Casual' },
|
| 344 |
+
{ name: 'Vindemiatrix', voice_id: 'Vindemiatrix', lang: 'en-US', description: 'Gentle' },
|
| 345 |
+
{ name: 'Sadachbia', voice_id: 'Sadachbia', lang: 'en-US', description: 'Lively' },
|
| 346 |
+
{ name: 'Sadaltager', voice_id: 'Sadaltager', lang: 'en-US', description: 'Knowledgeable' },
|
| 347 |
+
{ name: 'Sulafat', voice_id: 'Sulafat', lang: 'en-US', description: 'Warm' },
|
| 348 |
+
];
|
| 349 |
+
return response.json({ voices });
|
| 350 |
+
} catch (error) {
|
| 351 |
+
console.error('Failed to return Google TTS voices:', error);
|
| 352 |
+
response.sendStatus(500);
|
| 353 |
+
}
|
| 354 |
+
});
|
| 355 |
+
|
| 356 |
+
router.post('/generate-native-tts', async (request, response) => {
|
| 357 |
+
try {
|
| 358 |
+
const { text, voice, model } = request.body;
|
| 359 |
+
const { url, headers, apiName, safetySettings } = await getGoogleApiConfig(request, model);
|
| 360 |
+
|
| 361 |
+
console.debug(`${apiName} TTS request`, { model, text, voice });
|
| 362 |
+
|
| 363 |
+
const requestBody = {
|
| 364 |
+
contents: [{
|
| 365 |
+
role: 'user',
|
| 366 |
+
parts: [{ text: text }],
|
| 367 |
+
}],
|
| 368 |
+
generationConfig: {
|
| 369 |
+
responseModalities: ['AUDIO'],
|
| 370 |
+
speechConfig: {
|
| 371 |
+
voiceConfig: {
|
| 372 |
+
prebuiltVoiceConfig: {
|
| 373 |
+
voiceName: voice,
|
| 374 |
+
},
|
| 375 |
+
},
|
| 376 |
+
},
|
| 377 |
+
},
|
| 378 |
+
safetySettings: safetySettings,
|
| 379 |
+
};
|
| 380 |
+
|
| 381 |
+
const result = await fetch(url, {
|
| 382 |
+
method: 'POST',
|
| 383 |
+
headers: headers,
|
| 384 |
+
body: JSON.stringify(requestBody),
|
| 385 |
+
});
|
| 386 |
+
|
| 387 |
+
if (!result.ok) {
|
| 388 |
+
const errorText = await result.text();
|
| 389 |
+
console.error(`${apiName} TTS API error: ${result.status} ${result.statusText}`, errorText);
|
| 390 |
+
const errorMessage = JSON.parse(errorText).error?.message || 'TTS generation failed.';
|
| 391 |
+
return response.status(result.status).json({ error: errorMessage });
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
/** @type {any} */
|
| 395 |
+
const data = await result.json();
|
| 396 |
+
const audioPart = data?.candidates?.[0]?.content?.parts?.[0];
|
| 397 |
+
const audioData = audioPart?.inlineData?.data;
|
| 398 |
+
const mimeType = audioPart?.inlineData?.mimeType;
|
| 399 |
+
|
| 400 |
+
if (!audioData) {
|
| 401 |
+
return response.status(500).json({ error: 'No audio data found in response' });
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
const audioBuffer = Buffer.from(audioData, 'base64');
|
| 405 |
+
|
| 406 |
+
//If the audio is raw PCM, wrap it in a WAV header and send it.
|
| 407 |
+
if (mimeType && mimeType.toLowerCase().includes('audio/l16')) {
|
| 408 |
+
const rateMatch = mimeType.match(/rate=(\d+)/);
|
| 409 |
+
const sampleRate = rateMatch ? parseInt(rateMatch[1], 10) : 24000;
|
| 410 |
+
const pcmData = audioBuffer;
|
| 411 |
+
|
| 412 |
+
// Create a complete, playable WAV file buffer.
|
| 413 |
+
const wavBuffer = createCompleteWavFile(pcmData, sampleRate);
|
| 414 |
+
|
| 415 |
+
// Send the WAV file directly to the browser. This is much faster.
|
| 416 |
+
response.setHeader('Content-Type', 'audio/wav');
|
| 417 |
+
return response.send(wavBuffer);
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
// Fallback for any other audio format Google might send in the future.
|
| 421 |
+
response.setHeader('Content-Type', mimeType || 'application/octet-stream');
|
| 422 |
+
response.send(audioBuffer);
|
| 423 |
+
} catch (error) {
|
| 424 |
+
console.error('Google TTS generation failed:', error);
|
| 425 |
+
if (!response.headersSent) {
|
| 426 |
+
return response.status(500).json({ error: 'Internal server error during TTS generation' });
|
| 427 |
+
}
|
| 428 |
+
return response.end();
|
| 429 |
+
}
|
| 430 |
+
});
|
| 431 |
+
|
| 432 |
+
router.post('/generate-image', async (request, response) => {
|
| 433 |
+
try {
|
| 434 |
+
const model = request.body.model || 'imagen-3.0-generate-002';
|
| 435 |
+
const { url, headers, apiName } = await getGoogleApiConfig(request, model, 'predict');
|
| 436 |
+
|
| 437 |
+
// AI Studio is stricter than Vertex AI.
|
| 438 |
+
const isVertex = request.body.api === 'vertexai';
|
| 439 |
+
// Is it even worth it?
|
| 440 |
+
const isDeprecated = model.startsWith('imagegeneration');
|
| 441 |
+
// Get person generation setting from config
|
| 442 |
+
const personGeneration = getConfigValue('gemini.image.personGeneration', 'allow_adult');
|
| 443 |
+
|
| 444 |
+
const requestBody = {
|
| 445 |
+
instances: [{
|
| 446 |
+
prompt: request.body.prompt || '',
|
| 447 |
+
}],
|
| 448 |
+
parameters: {
|
| 449 |
+
sampleCount: 1,
|
| 450 |
+
seed: isVertex ? Number(request.body.seed ?? Math.floor(Math.random() * 1000000)) : undefined,
|
| 451 |
+
enhancePrompt: isVertex ? Boolean(request.body.enhance ?? false) : undefined,
|
| 452 |
+
negativePrompt: isVertex ? (request.body.negative_prompt || undefined) : undefined,
|
| 453 |
+
aspectRatio: String(request.body.aspect_ratio || '1:1'),
|
| 454 |
+
personGeneration: !isDeprecated && personGeneration ? personGeneration : undefined,
|
| 455 |
+
language: isVertex ? 'auto' : undefined,
|
| 456 |
+
safetySetting: !isDeprecated ? (isVertex ? 'block_only_high' : 'block_low_and_above') : undefined,
|
| 457 |
+
addWatermark: isVertex ? false : undefined,
|
| 458 |
+
outputOptions: {
|
| 459 |
+
mimeType: 'image/jpeg',
|
| 460 |
+
compressionQuality: 100,
|
| 461 |
+
},
|
| 462 |
+
},
|
| 463 |
+
};
|
| 464 |
+
|
| 465 |
+
console.debug(`${apiName} image generation request:`, model, requestBody);
|
| 466 |
+
|
| 467 |
+
const result = await fetch(url, {
|
| 468 |
+
method: 'POST',
|
| 469 |
+
headers: headers,
|
| 470 |
+
body: JSON.stringify(requestBody),
|
| 471 |
+
});
|
| 472 |
+
|
| 473 |
+
if (!result.ok) {
|
| 474 |
+
const errorText = await result.text();
|
| 475 |
+
console.warn(`${apiName} image generation error: ${result.status} ${result.statusText}`, errorText);
|
| 476 |
+
return response.status(500).send('Image generation request failed');
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
/** @type {any} */
|
| 480 |
+
const data = await result.json();
|
| 481 |
+
const imagePart = data?.predictions?.[0]?.bytesBase64Encoded;
|
| 482 |
+
|
| 483 |
+
if (!imagePart) {
|
| 484 |
+
console.warn(`${apiName} image generation error: No image data found in response`);
|
| 485 |
+
return response.status(500).send('No image data found in response');
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
return response.send({ image: imagePart });
|
| 489 |
+
} catch (error) {
|
| 490 |
+
console.error('Google Image generation failed:', error);
|
| 491 |
+
if (!response.headersSent) {
|
| 492 |
+
return response.sendStatus(500);
|
| 493 |
+
}
|
| 494 |
+
return response.end();
|
| 495 |
+
}
|
| 496 |
+
});
|
| 497 |
+
|
| 498 |
+
router.post('/generate-video', async (request, response) => {
|
| 499 |
+
try {
|
| 500 |
+
const controller = new AbortController();
|
| 501 |
+
request.socket.removeAllListeners('close');
|
| 502 |
+
request.socket.on('close', function () {
|
| 503 |
+
controller.abort();
|
| 504 |
+
});
|
| 505 |
+
|
| 506 |
+
const model = request.body.model || 'veo-3.1-generate-preview';
|
| 507 |
+
const { url, headers, apiName, baseUrl } = await getGoogleApiConfig(request, model, 'predictLongRunning');
|
| 508 |
+
const useVertexAi = request.body.api === 'vertexai';
|
| 509 |
+
|
| 510 |
+
const isVeo3 = /veo-3/.test(model);
|
| 511 |
+
const lowerBound = isVeo3 ? 4 : 5;
|
| 512 |
+
const upperBound = isVeo3 ? 8 : 8;
|
| 513 |
+
|
| 514 |
+
const requestBody = {
|
| 515 |
+
instances: [{
|
| 516 |
+
prompt: String(request.body.prompt || ''),
|
| 517 |
+
}],
|
| 518 |
+
parameters: {
|
| 519 |
+
negativePrompt: String(request.body.negative_prompt || ''),
|
| 520 |
+
durationSeconds: lodash.clamp(Number(request.body.seconds || 6), lowerBound, upperBound),
|
| 521 |
+
aspectRatio: String(request.body.aspect_ratio || '16:9'),
|
| 522 |
+
personGeneration: 'allow_all',
|
| 523 |
+
seed: isVeo3 ? Number(request.body.seed ?? Math.floor(Math.random() * 1000000)) : undefined,
|
| 524 |
+
},
|
| 525 |
+
};
|
| 526 |
+
|
| 527 |
+
console.debug(`${apiName} video generation request:`, model, requestBody);
|
| 528 |
+
const videoJobResponse = await fetch(url, {
|
| 529 |
+
method: 'POST',
|
| 530 |
+
headers: headers,
|
| 531 |
+
body: JSON.stringify(requestBody),
|
| 532 |
+
});
|
| 533 |
+
|
| 534 |
+
if (!videoJobResponse.ok) {
|
| 535 |
+
const errorText = await videoJobResponse.text();
|
| 536 |
+
console.warn(`${apiName} video generation error: ${videoJobResponse.status} ${videoJobResponse.statusText}`, errorText);
|
| 537 |
+
return response.status(500).send('Video generation request failed');
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
/** @type {any} */
|
| 541 |
+
const videoJobData = await videoJobResponse.json();
|
| 542 |
+
const videoJobName = videoJobData?.name;
|
| 543 |
+
|
| 544 |
+
if (!videoJobName) {
|
| 545 |
+
console.warn(`${apiName} video generation error: No job name found in response`);
|
| 546 |
+
return response.status(500).send('No video job name found in response');
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
console.debug(`${apiName} video job name:`, videoJobName);
|
| 550 |
+
|
| 551 |
+
for (let attempt = 0; attempt < 30; attempt++) {
|
| 552 |
+
if (controller.signal.aborted) {
|
| 553 |
+
console.info(`${apiName} video generation aborted by client`);
|
| 554 |
+
return response.status(500).send('Video generation aborted by client');
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
await delay(5000 + attempt * 1000);
|
| 558 |
+
|
| 559 |
+
if (useVertexAi) {
|
| 560 |
+
const { url: pollUrl, headers: pollHeaders } = await getGoogleApiConfig(request, model, 'fetchPredictOperation');
|
| 561 |
+
|
| 562 |
+
const pollResponse = await fetch(pollUrl, {
|
| 563 |
+
method: 'POST',
|
| 564 |
+
headers: pollHeaders,
|
| 565 |
+
body: JSON.stringify({ operationName: videoJobName }),
|
| 566 |
+
});
|
| 567 |
+
|
| 568 |
+
if (!pollResponse.ok) {
|
| 569 |
+
const errorText = await pollResponse.text();
|
| 570 |
+
console.warn(`${apiName} video job status error: ${pollResponse.status} ${pollResponse.statusText}`, errorText);
|
| 571 |
+
return response.status(500).send('Video job status request failed');
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
/** @type {any} */
|
| 575 |
+
const pollData = await pollResponse.json();
|
| 576 |
+
const jobDone = pollData?.done;
|
| 577 |
+
console.debug(`${apiName} video job status attempt ${attempt + 1}: ${jobDone ? 'done' : 'running'}`);
|
| 578 |
+
|
| 579 |
+
if (jobDone) {
|
| 580 |
+
const videoData = pollData?.response?.videos?.[0]?.bytesBase64Encoded;
|
| 581 |
+
if (!videoData) {
|
| 582 |
+
const pollDataLog = util.inspect(pollData, { depth: 5, colors: true, maxStringLength: 500 });
|
| 583 |
+
console.warn(`${apiName} video generation error: No video data found in response`, pollDataLog);
|
| 584 |
+
return response.status(500).send('No video data found in response');
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
+
return response.send({ video: videoData });
|
| 588 |
+
}
|
| 589 |
+
} else {
|
| 590 |
+
const pollUrl = urlJoin(baseUrl, videoJobName);
|
| 591 |
+
const pollResponse = await fetch(pollUrl, {
|
| 592 |
+
method: 'GET',
|
| 593 |
+
headers: headers,
|
| 594 |
+
});
|
| 595 |
+
|
| 596 |
+
if (!pollResponse.ok) {
|
| 597 |
+
const errorText = await pollResponse.text();
|
| 598 |
+
console.warn(`${apiName} video job status error: ${pollResponse.status} ${pollResponse.statusText}`, errorText);
|
| 599 |
+
return response.status(500).send('Video job status request failed');
|
| 600 |
+
}
|
| 601 |
+
|
| 602 |
+
/** @type {any} */
|
| 603 |
+
const pollData = await pollResponse.json();
|
| 604 |
+
const jobDone = pollData?.done;
|
| 605 |
+
console.debug(`${apiName} video job status attempt ${attempt + 1}: ${jobDone ? 'done' : 'running'}`);
|
| 606 |
+
|
| 607 |
+
if (jobDone) {
|
| 608 |
+
const videoUri = pollData?.response?.generateVideoResponse?.generatedSamples?.[0]?.video?.uri;
|
| 609 |
+
console.debug(`${apiName} video URI:`, videoUri);
|
| 610 |
+
|
| 611 |
+
if (!videoUri) {
|
| 612 |
+
const pollDataLog = util.inspect(pollData, { depth: 5, colors: true, maxStringLength: 500 });
|
| 613 |
+
console.warn(`${apiName} video generation error: No video URI found in response`, pollDataLog);
|
| 614 |
+
return response.status(500).send('No video URI found in response');
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
const videoResponse = await fetch(videoUri, {
|
| 618 |
+
method: 'GET',
|
| 619 |
+
headers: headers,
|
| 620 |
+
});
|
| 621 |
+
|
| 622 |
+
if (!videoResponse.ok) {
|
| 623 |
+
console.warn(`${apiName} video fetch error: ${videoResponse.status} ${videoResponse.statusText}`);
|
| 624 |
+
return response.status(500).send('Video fetch request failed');
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
const videoData = await videoResponse.arrayBuffer();
|
| 628 |
+
const videoBase64 = Buffer.from(videoData).toString('base64');
|
| 629 |
+
|
| 630 |
+
return response.send({ video: videoBase64 });
|
| 631 |
+
}
|
| 632 |
+
}
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
console.warn(`${apiName} video generation error: Job timed out after multiple attempts`);
|
| 636 |
+
return response.status(500).send('Video generation timed out');
|
| 637 |
+
} catch (error) {
|
| 638 |
+
console.error('Google Video generation failed:', error);
|
| 639 |
+
return response.sendStatus(500);
|
| 640 |
+
}
|
| 641 |
+
});
|
src/endpoints/groups.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from 'node:fs';
|
| 2 |
+
import { promises as fsPromises } from 'node:fs';
|
| 3 |
+
import path from 'node:path';
|
| 4 |
+
|
| 5 |
+
import express from 'express';
|
| 6 |
+
import sanitize from 'sanitize-filename';
|
| 7 |
+
import { sync as writeFileAtomicSync, default as writeFileAtomic } from 'write-file-atomic';
|
| 8 |
+
|
| 9 |
+
import { color, tryParse } from '../util.js';
|
| 10 |
+
import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
|
| 11 |
+
|
| 12 |
+
export const router = express.Router();
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* Warns if group data contains deprecated metadata keys and removes them.
|
| 16 |
+
* @param {object} groupData Group data object
|
| 17 |
+
*/
|
| 18 |
+
function warnOnGroupMetadata(groupData) {
|
| 19 |
+
if (typeof groupData !== 'object' || groupData === null) {
|
| 20 |
+
return;
|
| 21 |
+
}
|
| 22 |
+
['chat_metadata', 'past_metadata'].forEach(key => {
|
| 23 |
+
if (Object.hasOwn(groupData, key)) {
|
| 24 |
+
console.warn(color.yellow(`Group JSON data for "${groupData.id}" contains deprecated key "${key}".`));
|
| 25 |
+
delete groupData[key];
|
| 26 |
+
}
|
| 27 |
+
});
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/**
|
| 31 |
+
* Migrates group metadata to include chat metadata for each group chat instead of the group itself.
|
| 32 |
+
* @param {import('../users.js').UserDirectoryList[]} userDirectories Listing of all users' directories
|
| 33 |
+
*/
|
| 34 |
+
export async function migrateGroupChatsMetadataFormat(userDirectories) {
|
| 35 |
+
for (const userDirs of userDirectories) {
|
| 36 |
+
try {
|
| 37 |
+
let anyDataMigrated = false;
|
| 38 |
+
const backupPath = path.join(userDirs.backups, '_group_metadata_update');
|
| 39 |
+
const groupFiles = await fsPromises.readdir(userDirs.groups, { withFileTypes: true });
|
| 40 |
+
const groupChatFiles = await fsPromises.readdir(userDirs.groupChats, { withFileTypes: true });
|
| 41 |
+
for (const groupFile of groupFiles) {
|
| 42 |
+
try {
|
| 43 |
+
const isJsonFile = groupFile.isFile() && path.extname(groupFile.name) === '.json';
|
| 44 |
+
if (!isJsonFile) {
|
| 45 |
+
continue;
|
| 46 |
+
}
|
| 47 |
+
const groupFilePath = path.join(userDirs.groups, groupFile.name);
|
| 48 |
+
const groupDataRaw = await fsPromises.readFile(groupFilePath, 'utf8');
|
| 49 |
+
const groupData = tryParse(groupDataRaw) || {};
|
| 50 |
+
const needsMigration = ['chat_metadata', 'past_metadata'].some(key => Object.hasOwn(groupData, key));
|
| 51 |
+
if (!needsMigration) {
|
| 52 |
+
continue;
|
| 53 |
+
}
|
| 54 |
+
if (!fs.existsSync(backupPath)){
|
| 55 |
+
await fsPromises.mkdir(backupPath, { recursive: true });
|
| 56 |
+
}
|
| 57 |
+
await fsPromises.copyFile(groupFilePath, path.join(backupPath, groupFile.name));
|
| 58 |
+
const allMetadata = {
|
| 59 |
+
...(groupData.past_metadata || {}),
|
| 60 |
+
[groupData.chat_id]: (groupData.chat_metadata || {}),
|
| 61 |
+
};
|
| 62 |
+
if (!Array.isArray(groupData.chats)) {
|
| 63 |
+
console.warn(color.yellow(`Group ${groupFile.name} has no chats array, skipping migration.`));
|
| 64 |
+
continue;
|
| 65 |
+
}
|
| 66 |
+
for (const chatId of groupData.chats) {
|
| 67 |
+
try {
|
| 68 |
+
const chatFileName = sanitize(`${chatId}.jsonl`);
|
| 69 |
+
const chatFileDirent = groupChatFiles.find(f => f.isFile() && f.name === chatFileName);
|
| 70 |
+
if (!chatFileDirent) {
|
| 71 |
+
console.warn(color.yellow(`Group chat file ${chatId} not found, skipping migration.`));
|
| 72 |
+
continue;
|
| 73 |
+
}
|
| 74 |
+
const chatFilePath = path.join(userDirs.groupChats, chatFileName);
|
| 75 |
+
const chatMetadata = allMetadata[chatId] || {};
|
| 76 |
+
const chatDataRaw = await fsPromises.readFile(chatFilePath, 'utf8');
|
| 77 |
+
const chatData = chatDataRaw.split('\n').filter(line => line.trim()).map(line => tryParse(line)).filter(Boolean);
|
| 78 |
+
const alreadyHasMetadata = chatData.length > 0 && Object.hasOwn(chatData[0], 'chat_metadata');
|
| 79 |
+
if (alreadyHasMetadata) {
|
| 80 |
+
console.log(color.yellow(`Group chat ${chatId} already has chat metadata, skipping update.`));
|
| 81 |
+
continue;
|
| 82 |
+
}
|
| 83 |
+
await fsPromises.copyFile(chatFilePath, path.join(backupPath, chatFileName));
|
| 84 |
+
const chatHeader = { chat_metadata: chatMetadata, user_name: 'unused', character_name: 'unused' };
|
| 85 |
+
const newChatData = [chatHeader, ...chatData];
|
| 86 |
+
const newChatDataRaw = newChatData.map(entry => JSON.stringify(entry)).join('\n');
|
| 87 |
+
await writeFileAtomic(chatFilePath, newChatDataRaw, 'utf8');
|
| 88 |
+
console.log(`Updated group chat data format for ${chatId}`);
|
| 89 |
+
anyDataMigrated = true;
|
| 90 |
+
} catch (chatError) {
|
| 91 |
+
console.error(color.red(`Could not update existing chat data for ${chatId}`), chatError);
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
delete groupData.chat_metadata;
|
| 95 |
+
delete groupData.past_metadata;
|
| 96 |
+
await writeFileAtomic(groupFilePath, JSON.stringify(groupData, null, 4), 'utf8');
|
| 97 |
+
console.log(`Migrated group chats metadata for group: ${groupData.id}`);
|
| 98 |
+
anyDataMigrated = true;
|
| 99 |
+
} catch (groupError) {
|
| 100 |
+
console.error(color.red(`Could not process group file ${groupFile.name}`), groupError);
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
if (anyDataMigrated) {
|
| 104 |
+
console.log(color.green(`Completed migration of group chats metadata for user at ${userDirs.root}`));
|
| 105 |
+
console.log(color.cyan(`Backups of modified files are located at ${backupPath}`));
|
| 106 |
+
}
|
| 107 |
+
} catch (directoryError) {
|
| 108 |
+
console.error(color.red(`Error migrating group chats metadata for user at ${userDirs.root}`), directoryError);
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
router.post('/all', (request, response) => {
|
| 114 |
+
const groups = [];
|
| 115 |
+
|
| 116 |
+
if (!fs.existsSync(request.user.directories.groups)) {
|
| 117 |
+
fs.mkdirSync(request.user.directories.groups);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
const files = fs.readdirSync(request.user.directories.groups).filter(x => path.extname(x) === '.json');
|
| 121 |
+
const chats = fs.readdirSync(request.user.directories.groupChats).filter(x => path.extname(x) === '.jsonl');
|
| 122 |
+
|
| 123 |
+
files.forEach(function (file) {
|
| 124 |
+
try {
|
| 125 |
+
const filePath = path.join(request.user.directories.groups, file);
|
| 126 |
+
const fileContents = fs.readFileSync(filePath, 'utf8');
|
| 127 |
+
const group = JSON.parse(fileContents);
|
| 128 |
+
const groupStat = fs.statSync(filePath);
|
| 129 |
+
group['date_added'] = groupStat.birthtimeMs;
|
| 130 |
+
group['create_date'] = new Date(groupStat.birthtimeMs).toISOString();
|
| 131 |
+
|
| 132 |
+
let chat_size = 0;
|
| 133 |
+
let date_last_chat = 0;
|
| 134 |
+
|
| 135 |
+
if (Array.isArray(group.chats) && Array.isArray(chats)) {
|
| 136 |
+
for (const chat of chats) {
|
| 137 |
+
if (group.chats.includes(path.parse(chat).name)) {
|
| 138 |
+
const chatStat = fs.statSync(path.join(request.user.directories.groupChats, chat));
|
| 139 |
+
chat_size += chatStat.size;
|
| 140 |
+
date_last_chat = Math.max(date_last_chat, chatStat.mtimeMs);
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
group['date_last_chat'] = date_last_chat;
|
| 146 |
+
group['chat_size'] = chat_size;
|
| 147 |
+
groups.push(group);
|
| 148 |
+
}
|
| 149 |
+
catch (error) {
|
| 150 |
+
console.error(error);
|
| 151 |
+
}
|
| 152 |
+
});
|
| 153 |
+
|
| 154 |
+
return response.send(groups);
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
router.post('/create', (request, response) => {
|
| 158 |
+
if (!request.body) {
|
| 159 |
+
return response.sendStatus(400);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
warnOnGroupMetadata(request.body);
|
| 163 |
+
const id = String(Date.now());
|
| 164 |
+
const groupMetadata = {
|
| 165 |
+
id: id,
|
| 166 |
+
name: request.body.name ?? 'New Group',
|
| 167 |
+
members: request.body.members ?? [],
|
| 168 |
+
avatar_url: request.body.avatar_url,
|
| 169 |
+
allow_self_responses: !!request.body.allow_self_responses,
|
| 170 |
+
activation_strategy: request.body.activation_strategy ?? 0,
|
| 171 |
+
generation_mode: request.body.generation_mode ?? 0,
|
| 172 |
+
disabled_members: request.body.disabled_members ?? [],
|
| 173 |
+
fav: request.body.fav,
|
| 174 |
+
chat_id: request.body.chat_id ?? id,
|
| 175 |
+
chats: request.body.chats ?? [id],
|
| 176 |
+
auto_mode_delay: request.body.auto_mode_delay ?? 5,
|
| 177 |
+
generation_mode_join_prefix: request.body.generation_mode_join_prefix ?? '',
|
| 178 |
+
generation_mode_join_suffix: request.body.generation_mode_join_suffix ?? '',
|
| 179 |
+
};
|
| 180 |
+
const pathToFile = path.join(request.user.directories.groups, sanitize(`${id}.json`));
|
| 181 |
+
const fileData = JSON.stringify(groupMetadata, null, 4);
|
| 182 |
+
|
| 183 |
+
if (!fs.existsSync(request.user.directories.groups)) {
|
| 184 |
+
fs.mkdirSync(request.user.directories.groups);
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
writeFileAtomicSync(pathToFile, fileData);
|
| 188 |
+
return response.send(groupMetadata);
|
| 189 |
+
});
|
| 190 |
+
|
| 191 |
+
router.post('/edit', getFileNameValidationFunction('id'), (request, response) => {
|
| 192 |
+
if (!request.body || !request.body.id) {
|
| 193 |
+
return response.sendStatus(400);
|
| 194 |
+
}
|
| 195 |
+
warnOnGroupMetadata(request.body);
|
| 196 |
+
const id = request.body.id;
|
| 197 |
+
const pathToFile = path.join(request.user.directories.groups, sanitize(`${id}.json`));
|
| 198 |
+
const fileData = JSON.stringify(request.body, null, 4);
|
| 199 |
+
|
| 200 |
+
writeFileAtomicSync(pathToFile, fileData);
|
| 201 |
+
return response.send({ ok: true });
|
| 202 |
+
});
|
| 203 |
+
|
| 204 |
+
router.post('/delete', getFileNameValidationFunction('id'), async (request, response) => {
|
| 205 |
+
if (!request.body || !request.body.id) {
|
| 206 |
+
return response.sendStatus(400);
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
const id = request.body.id;
|
| 210 |
+
const pathToGroup = path.join(request.user.directories.groups, sanitize(`${id}.json`));
|
| 211 |
+
|
| 212 |
+
try {
|
| 213 |
+
// Delete group chats
|
| 214 |
+
const group = JSON.parse(fs.readFileSync(pathToGroup, 'utf8'));
|
| 215 |
+
|
| 216 |
+
if (group && Array.isArray(group.chats)) {
|
| 217 |
+
for (const chat of group.chats) {
|
| 218 |
+
console.info('Deleting group chat', chat);
|
| 219 |
+
const pathToFile = path.join(request.user.directories.groupChats, sanitize(`${chat}.jsonl`));
|
| 220 |
+
|
| 221 |
+
if (fs.existsSync(pathToFile)) {
|
| 222 |
+
fs.unlinkSync(pathToFile);
|
| 223 |
+
}
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
} catch (error) {
|
| 227 |
+
console.error('Could not delete group chats. Clean them up manually.', error);
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
if (fs.existsSync(pathToGroup)) {
|
| 231 |
+
fs.unlinkSync(pathToGroup);
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
return response.send({ ok: true });
|
| 235 |
+
});
|
src/endpoints/horde.js
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fetch from 'node-fetch';
|
| 2 |
+
import express from 'express';
|
| 3 |
+
import { AIHorde, ModelGenerationInputStableSamplers, ModelInterrogationFormTypes, HordeAsyncRequestStates } from '@zeldafan0225/ai_horde';
|
| 4 |
+
import { getVersion, delay, Cache } from '../util.js';
|
| 5 |
+
import { readSecret, SECRET_KEYS } from './secrets.js';
|
| 6 |
+
|
| 7 |
+
const ANONYMOUS_KEY = '0000000000';
|
| 8 |
+
const HORDE_TEXT_MODEL_METADATA_URL = 'https://raw.githubusercontent.com/db0/AI-Horde-text-model-reference/main/db.json';
|
| 9 |
+
const cache = new Cache(60 * 1000);
|
| 10 |
+
export const router = express.Router();
|
| 11 |
+
|
| 12 |
+
/**
|
| 13 |
+
* Returns the AIHorde client agent.
|
| 14 |
+
* @returns {Promise<string>} AIHorde client agent
|
| 15 |
+
*/
|
| 16 |
+
async function getClientAgent() {
|
| 17 |
+
const version = await getVersion();
|
| 18 |
+
return version?.agent || 'TavernIntern:UNKNOWN:Cohee#1207';
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
/**
|
| 22 |
+
* Returns the AIHorde client.
|
| 23 |
+
* @returns {Promise<AIHorde>} AIHorde client
|
| 24 |
+
*/
|
| 25 |
+
async function getHordeClient() {
|
| 26 |
+
return new AIHorde({
|
| 27 |
+
client_agent: await getClientAgent(),
|
| 28 |
+
});
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
/**
|
| 32 |
+
* Removes dirty no-no words from the prompt.
|
| 33 |
+
* Taken verbatim from KAI Lite's implementation (AGPLv3).
|
| 34 |
+
* https://github.com/LostRuins/lite.koboldai.net/blob/main/index.html#L7786C2-L7811C1
|
| 35 |
+
* @param {string} prompt Prompt to sanitize
|
| 36 |
+
* @returns {string} Sanitized prompt
|
| 37 |
+
*/
|
| 38 |
+
function sanitizeHordeImagePrompt(prompt) {
|
| 39 |
+
if (!prompt) {
|
| 40 |
+
return '';
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
//to avoid flagging from some image models, always swap these words
|
| 44 |
+
prompt = prompt.replace(/\b(girl)\b/gmi, 'woman');
|
| 45 |
+
prompt = prompt.replace(/\b(boy)\b/gmi, 'man');
|
| 46 |
+
prompt = prompt.replace(/\b(girls)\b/gmi, 'women');
|
| 47 |
+
prompt = prompt.replace(/\b(boys)\b/gmi, 'men');
|
| 48 |
+
//always remove these high risk words from prompt, as they add little value to image gen while increasing the risk the prompt gets flagged
|
| 49 |
+
prompt = prompt.replace(/\b(under.age|under.aged|underage|underaged|loli|pedo|pedophile|(\w+).year.old|(\w+).years.old|minor|prepubescent|minors|shota)\b/gmi, '');
|
| 50 |
+
//replace risky subject nouns with person
|
| 51 |
+
prompt = prompt.replace(/\b(youngster|infant|baby|toddler|child|teen|kid|kiddie|kiddo|teenager|student|preteen|pre.teen)\b/gmi, 'person');
|
| 52 |
+
//remove risky adjectives and related words
|
| 53 |
+
prompt = prompt.replace(/\b(young|younger|youthful|youth|small|smaller|smallest|girly|boyish|lil|tiny|teenaged|lit[tl]le|school.aged|school|highschool|kindergarten|teens|children|kids)\b/gmi, '');
|
| 54 |
+
|
| 55 |
+
return prompt;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
router.post('/text-workers', async (request, response) => {
|
| 59 |
+
try {
|
| 60 |
+
const cachedWorkers = cache.get('workers');
|
| 61 |
+
|
| 62 |
+
if (cachedWorkers && !request.body.force) {
|
| 63 |
+
return response.send(cachedWorkers);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
const agent = await getClientAgent();
|
| 67 |
+
const fetchResult = await fetch('https://aihorde.net/api/v2/workers?type=text', {
|
| 68 |
+
headers: {
|
| 69 |
+
'Client-Agent': agent,
|
| 70 |
+
},
|
| 71 |
+
});
|
| 72 |
+
const data = await fetchResult.json();
|
| 73 |
+
cache.set('workers', data);
|
| 74 |
+
return response.send(data);
|
| 75 |
+
} catch (error) {
|
| 76 |
+
console.error(error);
|
| 77 |
+
response.sendStatus(500);
|
| 78 |
+
}
|
| 79 |
+
});
|
| 80 |
+
|
| 81 |
+
async function getHordeTextModelMetadata() {
|
| 82 |
+
const response = await fetch(HORDE_TEXT_MODEL_METADATA_URL);
|
| 83 |
+
return await response.json();
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
async function mergeModelsAndMetadata(models, metadata) {
|
| 87 |
+
return models.map(model => {
|
| 88 |
+
const metadataModel = metadata[model.name];
|
| 89 |
+
if (!metadataModel) {
|
| 90 |
+
return { ...model, is_whitelisted: false };
|
| 91 |
+
}
|
| 92 |
+
return { ...model, ...metadataModel, is_whitelisted: true };
|
| 93 |
+
});
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
router.post('/text-models', async (request, response) => {
|
| 97 |
+
try {
|
| 98 |
+
const cachedModels = cache.get('models');
|
| 99 |
+
if (cachedModels && !request.body.force) {
|
| 100 |
+
return response.send(cachedModels);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
const agent = await getClientAgent();
|
| 104 |
+
const fetchResult = await fetch('https://aihorde.net/api/v2/status/models?type=text', {
|
| 105 |
+
headers: {
|
| 106 |
+
'Client-Agent': agent,
|
| 107 |
+
},
|
| 108 |
+
});
|
| 109 |
+
|
| 110 |
+
let data = await fetchResult.json();
|
| 111 |
+
|
| 112 |
+
// attempt to fetch and merge models metadata
|
| 113 |
+
try {
|
| 114 |
+
const metadata = await getHordeTextModelMetadata();
|
| 115 |
+
data = await mergeModelsAndMetadata(data, metadata);
|
| 116 |
+
}
|
| 117 |
+
catch (error) {
|
| 118 |
+
console.error('Failed to fetch metadata:', error);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
cache.set('models', data);
|
| 122 |
+
return response.send(data);
|
| 123 |
+
} catch (error) {
|
| 124 |
+
console.error(error);
|
| 125 |
+
response.sendStatus(500);
|
| 126 |
+
}
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
router.post('/status', async (_, response) => {
|
| 130 |
+
try {
|
| 131 |
+
const agent = await getClientAgent();
|
| 132 |
+
const fetchResult = await fetch('https://aihorde.net/api/v2/status/heartbeat', {
|
| 133 |
+
headers: {
|
| 134 |
+
'Client-Agent': agent,
|
| 135 |
+
},
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
return response.send({ ok: fetchResult.ok });
|
| 139 |
+
} catch (error) {
|
| 140 |
+
console.error(error);
|
| 141 |
+
response.sendStatus(500);
|
| 142 |
+
}
|
| 143 |
+
});
|
| 144 |
+
|
| 145 |
+
router.post('/cancel-task', async (request, response) => {
|
| 146 |
+
try {
|
| 147 |
+
const taskId = request.body.taskId;
|
| 148 |
+
const agent = await getClientAgent();
|
| 149 |
+
const fetchResult = await fetch(`https://aihorde.net/api/v2/generate/text/status/${taskId}`, {
|
| 150 |
+
method: 'DELETE',
|
| 151 |
+
headers: {
|
| 152 |
+
'Client-Agent': agent,
|
| 153 |
+
},
|
| 154 |
+
});
|
| 155 |
+
|
| 156 |
+
const data = await fetchResult.json();
|
| 157 |
+
console.info(`Cancelled Horde task ${taskId}`);
|
| 158 |
+
return response.send(data);
|
| 159 |
+
} catch (error) {
|
| 160 |
+
console.error(error);
|
| 161 |
+
response.sendStatus(500);
|
| 162 |
+
}
|
| 163 |
+
});
|
| 164 |
+
|
| 165 |
+
router.post('/task-status', async (request, response) => {
|
| 166 |
+
try {
|
| 167 |
+
const taskId = request.body.taskId;
|
| 168 |
+
const agent = await getClientAgent();
|
| 169 |
+
const fetchResult = await fetch(`https://aihorde.net/api/v2/generate/text/status/${taskId}`, {
|
| 170 |
+
headers: {
|
| 171 |
+
'Client-Agent': agent,
|
| 172 |
+
},
|
| 173 |
+
});
|
| 174 |
+
|
| 175 |
+
const data = await fetchResult.json();
|
| 176 |
+
console.info(`Horde task ${taskId} status:`, data);
|
| 177 |
+
return response.send(data);
|
| 178 |
+
} catch (error) {
|
| 179 |
+
console.error(error);
|
| 180 |
+
response.sendStatus(500);
|
| 181 |
+
}
|
| 182 |
+
});
|
| 183 |
+
|
| 184 |
+
router.post('/generate-text', async (request, response) => {
|
| 185 |
+
const apiKey = readSecret(request.user.directories, SECRET_KEYS.HORDE) || ANONYMOUS_KEY;
|
| 186 |
+
const url = 'https://aihorde.net/api/v2/generate/text/async';
|
| 187 |
+
const agent = await getClientAgent();
|
| 188 |
+
|
| 189 |
+
console.debug(request.body);
|
| 190 |
+
try {
|
| 191 |
+
const result = await fetch(url, {
|
| 192 |
+
method: 'POST',
|
| 193 |
+
body: JSON.stringify(request.body),
|
| 194 |
+
headers: {
|
| 195 |
+
'Content-Type': 'application/json',
|
| 196 |
+
'apikey': apiKey,
|
| 197 |
+
'Client-Agent': agent,
|
| 198 |
+
},
|
| 199 |
+
});
|
| 200 |
+
|
| 201 |
+
if (!result.ok) {
|
| 202 |
+
const message = await result.text();
|
| 203 |
+
console.error('Horde returned an error:', message);
|
| 204 |
+
return response.send({ error: { message } });
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
const data = await result.json();
|
| 208 |
+
return response.send(data);
|
| 209 |
+
} catch (error) {
|
| 210 |
+
console.error(error);
|
| 211 |
+
return response.send({ error: true });
|
| 212 |
+
}
|
| 213 |
+
});
|
| 214 |
+
|
| 215 |
+
router.post('/sd-samplers', async (_, response) => {
|
| 216 |
+
try {
|
| 217 |
+
const samplers = Object.values(ModelGenerationInputStableSamplers);
|
| 218 |
+
response.send(samplers);
|
| 219 |
+
} catch (error) {
|
| 220 |
+
console.error(error);
|
| 221 |
+
response.sendStatus(500);
|
| 222 |
+
}
|
| 223 |
+
});
|
| 224 |
+
|
| 225 |
+
router.post('/sd-models', async (_, response) => {
|
| 226 |
+
try {
|
| 227 |
+
const ai_horde = await getHordeClient();
|
| 228 |
+
const models = await ai_horde.getModels();
|
| 229 |
+
response.send(models);
|
| 230 |
+
} catch (error) {
|
| 231 |
+
console.error(error);
|
| 232 |
+
response.sendStatus(500);
|
| 233 |
+
}
|
| 234 |
+
});
|
| 235 |
+
|
| 236 |
+
router.post('/caption-image', async (request, response) => {
|
| 237 |
+
try {
|
| 238 |
+
const api_key_horde = readSecret(request.user.directories, SECRET_KEYS.HORDE) || ANONYMOUS_KEY;
|
| 239 |
+
const ai_horde = await getHordeClient();
|
| 240 |
+
const result = await ai_horde.postAsyncInterrogate({
|
| 241 |
+
source_image: request.body.image,
|
| 242 |
+
forms: [{ name: ModelInterrogationFormTypes.caption }],
|
| 243 |
+
}, { token: api_key_horde });
|
| 244 |
+
|
| 245 |
+
if (!result.id) {
|
| 246 |
+
console.error('Image interrogation request is not satisfyable:', result.message || 'unknown error');
|
| 247 |
+
return response.sendStatus(400);
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
const MAX_ATTEMPTS = 200;
|
| 251 |
+
const CHECK_INTERVAL = 3000;
|
| 252 |
+
|
| 253 |
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
| 254 |
+
await delay(CHECK_INTERVAL);
|
| 255 |
+
const status = await ai_horde.getInterrogationStatus(result.id);
|
| 256 |
+
console.info(status);
|
| 257 |
+
|
| 258 |
+
if (status.state === HordeAsyncRequestStates.done) {
|
| 259 |
+
|
| 260 |
+
if (status.forms === undefined) {
|
| 261 |
+
console.error('Image interrogation request failed: no forms found.');
|
| 262 |
+
return response.sendStatus(500);
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
console.debug('Image interrogation result:', status);
|
| 266 |
+
const caption = status?.forms[0]?.result?.caption || '';
|
| 267 |
+
|
| 268 |
+
if (!caption) {
|
| 269 |
+
console.error('Image interrogation request failed: no caption found.');
|
| 270 |
+
return response.sendStatus(500);
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
return response.send({ caption });
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
if (status.state === HordeAsyncRequestStates.faulted || status.state === HordeAsyncRequestStates.cancelled) {
|
| 277 |
+
console.error('Image interrogation request is not successful.');
|
| 278 |
+
return response.sendStatus(503);
|
| 279 |
+
}
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
} catch (error) {
|
| 283 |
+
console.error(error);
|
| 284 |
+
response.sendStatus(500);
|
| 285 |
+
}
|
| 286 |
+
});
|
| 287 |
+
|
| 288 |
+
router.post('/user-info', async (request, response) => {
|
| 289 |
+
const api_key_horde = readSecret(request.user.directories, SECRET_KEYS.HORDE);
|
| 290 |
+
|
| 291 |
+
if (!api_key_horde) {
|
| 292 |
+
return response.send({ anonymous: true });
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
try {
|
| 296 |
+
const ai_horde = await getHordeClient();
|
| 297 |
+
const sharedKey = await (async () => {
|
| 298 |
+
try {
|
| 299 |
+
return await ai_horde.getSharedKey(api_key_horde);
|
| 300 |
+
} catch {
|
| 301 |
+
return null;
|
| 302 |
+
}
|
| 303 |
+
})();
|
| 304 |
+
const user = await ai_horde.findUser({ token: api_key_horde });
|
| 305 |
+
return response.send({ user, sharedKey, anonymous: false });
|
| 306 |
+
} catch (error) {
|
| 307 |
+
console.error(error);
|
| 308 |
+
return response.sendStatus(500);
|
| 309 |
+
}
|
| 310 |
+
});
|
| 311 |
+
|
| 312 |
+
router.post('/generate-image', async (request, response) => {
|
| 313 |
+
if (!request.body.prompt) {
|
| 314 |
+
return response.sendStatus(400);
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
const MAX_ATTEMPTS = 200;
|
| 318 |
+
const CHECK_INTERVAL = 3000;
|
| 319 |
+
const PROMPT_THRESHOLD = 5000;
|
| 320 |
+
|
| 321 |
+
try {
|
| 322 |
+
const maxLength = PROMPT_THRESHOLD - String(request.body.negative_prompt).length - 5;
|
| 323 |
+
if (String(request.body.prompt).length > maxLength) {
|
| 324 |
+
console.warn('Stable Horde prompt is too long, truncating...');
|
| 325 |
+
request.body.prompt = String(request.body.prompt).substring(0, maxLength);
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
// Sanitize prompt if requested
|
| 329 |
+
if (request.body.sanitize) {
|
| 330 |
+
const sanitized = sanitizeHordeImagePrompt(request.body.prompt);
|
| 331 |
+
|
| 332 |
+
if (request.body.prompt !== sanitized) {
|
| 333 |
+
console.info('Stable Horde prompt was sanitized.');
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
request.body.prompt = sanitized;
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
const api_key_horde = readSecret(request.user.directories, SECRET_KEYS.HORDE) || ANONYMOUS_KEY;
|
| 340 |
+
console.debug('Stable Horde request:', request.body);
|
| 341 |
+
|
| 342 |
+
const ai_horde = await getHordeClient();
|
| 343 |
+
// noinspection JSCheckFunctionSignatures -- see @ts-ignore - use_gfpgan
|
| 344 |
+
const generation = await ai_horde.postAsyncImageGenerate(
|
| 345 |
+
{
|
| 346 |
+
prompt: `${request.body.prompt} ### ${request.body.negative_prompt}`,
|
| 347 |
+
params:
|
| 348 |
+
{
|
| 349 |
+
sampler_name: request.body.sampler,
|
| 350 |
+
hires_fix: request.body.enable_hr,
|
| 351 |
+
// @ts-ignore - use_gfpgan param is not in the type definition, need to update to new ai_horde @ https://github.com/ZeldaFan0225/ai_horde/blob/main/index.ts
|
| 352 |
+
use_gfpgan: request.body.restore_faces,
|
| 353 |
+
cfg_scale: request.body.scale,
|
| 354 |
+
steps: request.body.steps,
|
| 355 |
+
width: request.body.width,
|
| 356 |
+
height: request.body.height,
|
| 357 |
+
karras: Boolean(request.body.karras),
|
| 358 |
+
clip_skip: request.body.clip_skip,
|
| 359 |
+
seed: request.body.seed >= 0 ? String(request.body.seed) : undefined,
|
| 360 |
+
n: 1,
|
| 361 |
+
},
|
| 362 |
+
r2: false,
|
| 363 |
+
nsfw: request.body.nfsw,
|
| 364 |
+
models: [request.body.model],
|
| 365 |
+
},
|
| 366 |
+
{ token: api_key_horde });
|
| 367 |
+
|
| 368 |
+
if (!generation.id) {
|
| 369 |
+
console.warn('Image generation request is not satisfyable:', generation.message || 'unknown error');
|
| 370 |
+
return response.sendStatus(400);
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
console.info('Horde image generation request:', generation);
|
| 374 |
+
|
| 375 |
+
const controller = new AbortController();
|
| 376 |
+
request.socket.removeAllListeners('close');
|
| 377 |
+
request.socket.on('close', function () {
|
| 378 |
+
console.warn('Horde image generation request aborted.');
|
| 379 |
+
controller.abort();
|
| 380 |
+
if (generation.id) ai_horde.deleteImageGenerationRequest(generation.id);
|
| 381 |
+
});
|
| 382 |
+
|
| 383 |
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
| 384 |
+
controller.signal.throwIfAborted();
|
| 385 |
+
await delay(CHECK_INTERVAL);
|
| 386 |
+
const check = await ai_horde.getImageGenerationCheck(generation.id);
|
| 387 |
+
console.info(check);
|
| 388 |
+
|
| 389 |
+
if (check.done) {
|
| 390 |
+
const result = await ai_horde.getImageGenerationStatus(generation.id);
|
| 391 |
+
if (result.generations === undefined) return response.sendStatus(500);
|
| 392 |
+
return response.send(result.generations[0].img);
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
/*
|
| 396 |
+
if (!check.is_possible) {
|
| 397 |
+
return response.sendStatus(503);
|
| 398 |
+
}
|
| 399 |
+
*/
|
| 400 |
+
|
| 401 |
+
if (check.faulted) {
|
| 402 |
+
return response.sendStatus(500);
|
| 403 |
+
}
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
return response.sendStatus(504);
|
| 407 |
+
} catch (error) {
|
| 408 |
+
console.error(error);
|
| 409 |
+
return response.sendStatus(500);
|
| 410 |
+
}
|
| 411 |
+
});
|
src/endpoints/images.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from 'node:fs';
|
| 2 |
+
import path from 'node:path';
|
| 3 |
+
import { Buffer } from 'node:buffer';
|
| 4 |
+
|
| 5 |
+
import express from 'express';
|
| 6 |
+
import sanitize from 'sanitize-filename';
|
| 7 |
+
|
| 8 |
+
import { clientRelativePath, removeFileExtension, getImages, isPathUnderParent } from '../util.js';
|
| 9 |
+
import { MEDIA_EXTENSIONS, MEDIA_REQUEST_TYPE } from '../constants.js';
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* Ensure the directory for the provided file path exists.
|
| 13 |
+
* If not, it will recursively create the directory.
|
| 14 |
+
*
|
| 15 |
+
* @param {string} filePath - The full path of the file for which the directory should be ensured.
|
| 16 |
+
*/
|
| 17 |
+
function ensureDirectoryExistence(filePath) {
|
| 18 |
+
const dirname = path.dirname(filePath);
|
| 19 |
+
if (fs.existsSync(dirname)) {
|
| 20 |
+
return true;
|
| 21 |
+
}
|
| 22 |
+
ensureDirectoryExistence(dirname);
|
| 23 |
+
fs.mkdirSync(dirname);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export const router = express.Router();
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* Endpoint to handle image uploads.
|
| 30 |
+
* The image should be provided in the request body in base64 format.
|
| 31 |
+
* Optionally, a character name can be provided to save the image in a sub-folder.
|
| 32 |
+
*
|
| 33 |
+
* @route POST /api/images/upload
|
| 34 |
+
* @param {Object} request.body - The request payload.
|
| 35 |
+
* @param {string} request.body.image - The base64 encoded image data.
|
| 36 |
+
* @param {string} [request.body.ch_name] - Optional character name to determine the sub-directory.
|
| 37 |
+
* @returns {Object} response - The response object containing the path where the image was saved.
|
| 38 |
+
*/
|
| 39 |
+
router.post('/upload', async (request, response) => {
|
| 40 |
+
try {
|
| 41 |
+
if (!request.body) {
|
| 42 |
+
return response.status(400).send({ error: 'No data provided' });
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const { image, format } = request.body;
|
| 46 |
+
|
| 47 |
+
if (!image) {
|
| 48 |
+
return response.status(400).send({ error: 'No image data provided' });
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
const validFormat = MEDIA_EXTENSIONS.includes(format);
|
| 52 |
+
if (!validFormat) {
|
| 53 |
+
return response.status(400).send({ error: 'Invalid image format' });
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// Constructing filename and path
|
| 57 |
+
let filename;
|
| 58 |
+
if (request.body.filename) {
|
| 59 |
+
filename = `${removeFileExtension(request.body.filename)}.${format}`;
|
| 60 |
+
} else {
|
| 61 |
+
filename = `${Date.now()}.${format}`;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// if character is defined, save to a sub folder for that character
|
| 65 |
+
let pathToNewFile = path.join(request.user.directories.userImages, sanitize(filename));
|
| 66 |
+
if (request.body.ch_name) {
|
| 67 |
+
pathToNewFile = path.join(request.user.directories.userImages, sanitize(request.body.ch_name), sanitize(filename));
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
ensureDirectoryExistence(pathToNewFile);
|
| 71 |
+
const imageBuffer = Buffer.from(image, 'base64');
|
| 72 |
+
await fs.promises.writeFile(pathToNewFile, new Uint8Array(imageBuffer));
|
| 73 |
+
response.send({ path: clientRelativePath(request.user.directories.root, pathToNewFile) });
|
| 74 |
+
} catch (error) {
|
| 75 |
+
console.error(error);
|
| 76 |
+
response.status(500).send({ error: 'Failed to save the image' });
|
| 77 |
+
}
|
| 78 |
+
});
|
| 79 |
+
|
| 80 |
+
router.post('/list/:folder?', (request, response) => {
|
| 81 |
+
try {
|
| 82 |
+
if (request.params.folder) {
|
| 83 |
+
if (request.body.folder) {
|
| 84 |
+
return response.status(400).send({ error: 'Folder specified in both URL and body' });
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
console.warn('Deprecated: Use POST /api/images/list with folder in request body');
|
| 88 |
+
request.body.folder = request.params.folder;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
if (!request.body.folder) {
|
| 92 |
+
return response.status(400).send({ error: 'No folder specified' });
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
const directoryPath = path.join(request.user.directories.userImages, sanitize(request.body.folder));
|
| 96 |
+
const type = Number(request.body.type ?? MEDIA_REQUEST_TYPE.IMAGE);
|
| 97 |
+
const sort = request.body.sortField || 'date';
|
| 98 |
+
const order = request.body.sortOrder || 'asc';
|
| 99 |
+
|
| 100 |
+
if (!fs.existsSync(directoryPath)) {
|
| 101 |
+
fs.mkdirSync(directoryPath, { recursive: true });
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
const images = getImages(directoryPath, sort, type);
|
| 105 |
+
if (order === 'desc') {
|
| 106 |
+
images.reverse();
|
| 107 |
+
}
|
| 108 |
+
return response.send(images);
|
| 109 |
+
} catch (error) {
|
| 110 |
+
console.error(error);
|
| 111 |
+
return response.status(500).send({ error: 'Unable to retrieve files' });
|
| 112 |
+
}
|
| 113 |
+
});
|
| 114 |
+
|
| 115 |
+
router.post('/folders', (request, response) => {
|
| 116 |
+
try {
|
| 117 |
+
const directoryPath = request.user.directories.userImages;
|
| 118 |
+
if (!fs.existsSync(directoryPath)) {
|
| 119 |
+
fs.mkdirSync(directoryPath, { recursive: true });
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
const folders = fs.readdirSync(directoryPath, { withFileTypes: true })
|
| 123 |
+
.filter(dirent => dirent.isDirectory())
|
| 124 |
+
.map(dirent => dirent.name);
|
| 125 |
+
|
| 126 |
+
return response.send(folders);
|
| 127 |
+
} catch (error) {
|
| 128 |
+
console.error(error);
|
| 129 |
+
return response.status(500).send({ error: 'Unable to retrieve folders' });
|
| 130 |
+
}
|
| 131 |
+
});
|
| 132 |
+
|
| 133 |
+
router.post('/delete', async (request, response) => {
|
| 134 |
+
try {
|
| 135 |
+
if (!request.body.path) {
|
| 136 |
+
return response.status(400).send('No path specified');
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
const pathToDelete = path.join(request.user.directories.root, request.body.path);
|
| 140 |
+
if (!isPathUnderParent(request.user.directories.userImages, pathToDelete)) {
|
| 141 |
+
return response.status(400).send('Invalid path');
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
if (!fs.existsSync(pathToDelete)) {
|
| 145 |
+
return response.status(404).send('File not found');
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
fs.unlinkSync(pathToDelete);
|
| 149 |
+
console.info(`Deleted image: ${request.body.path} from ${request.user.profile.handle}`);
|
| 150 |
+
return response.sendStatus(200);
|
| 151 |
+
} catch (error) {
|
| 152 |
+
console.error(error);
|
| 153 |
+
return response.sendStatus(500);
|
| 154 |
+
}
|
| 155 |
+
});
|
src/endpoints/minimax.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import express from 'express';
|
| 2 |
+
import fetch from 'node-fetch';
|
| 3 |
+
import { readSecret, SECRET_KEYS } from './secrets.js';
|
| 4 |
+
|
| 5 |
+
export const router = express.Router();
|
| 6 |
+
|
| 7 |
+
// Audio format MIME type mapping
|
| 8 |
+
const getAudioMimeType = (format) => {
|
| 9 |
+
const mimeTypes = {
|
| 10 |
+
'mp3': 'audio/mpeg',
|
| 11 |
+
'wav': 'audio/wav',
|
| 12 |
+
'pcm': 'audio/pcm',
|
| 13 |
+
'flac': 'audio/flac',
|
| 14 |
+
'aac': 'audio/aac',
|
| 15 |
+
};
|
| 16 |
+
return mimeTypes[format] || 'audio/mpeg';
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
router.post('/generate-voice', async (request, response) => {
|
| 20 |
+
try {
|
| 21 |
+
const {
|
| 22 |
+
text,
|
| 23 |
+
voiceId,
|
| 24 |
+
apiHost = 'https://api.minimax.io',
|
| 25 |
+
model = 'speech-02-hd',
|
| 26 |
+
speed = 1.0,
|
| 27 |
+
volume = 1.0,
|
| 28 |
+
pitch = 1.0,
|
| 29 |
+
audioSampleRate = 32000,
|
| 30 |
+
bitrate = 128000,
|
| 31 |
+
format = 'mp3',
|
| 32 |
+
language,
|
| 33 |
+
} = request.body;
|
| 34 |
+
|
| 35 |
+
const apiKey = readSecret(request.user.directories, SECRET_KEYS.MINIMAX);
|
| 36 |
+
const groupId = readSecret(request.user.directories, SECRET_KEYS.MINIMAX_GROUP_ID);
|
| 37 |
+
|
| 38 |
+
// Validate required parameters
|
| 39 |
+
if (!text || !voiceId || !apiKey || !groupId) {
|
| 40 |
+
console.warn('MiniMax TTS: Missing required parameters');
|
| 41 |
+
return response.status(400).json({ error: 'Missing required parameters: text, voiceId, apiKey, and groupId are required' });
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
const requestBody = {
|
| 45 |
+
model: model,
|
| 46 |
+
text: text,
|
| 47 |
+
stream: false,
|
| 48 |
+
voice_setting: {
|
| 49 |
+
voice_id: voiceId,
|
| 50 |
+
speed: Number(speed),
|
| 51 |
+
vol: Number(volume),
|
| 52 |
+
pitch: Number(pitch),
|
| 53 |
+
},
|
| 54 |
+
audio_setting: {
|
| 55 |
+
sample_rate: Number(audioSampleRate),
|
| 56 |
+
bitrate: Number(bitrate),
|
| 57 |
+
format: format,
|
| 58 |
+
channel: 1,
|
| 59 |
+
},
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
// Add language parameter if provided
|
| 63 |
+
if (language) {
|
| 64 |
+
requestBody.lang = language;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
const apiUrl = `${apiHost}/v1/t2a_v2?GroupId=${groupId}`;
|
| 68 |
+
|
| 69 |
+
console.debug('MiniMax TTS Request:', {
|
| 70 |
+
url: apiUrl,
|
| 71 |
+
body: { ...requestBody, voice_setting: { ...requestBody.voice_setting, voice_id: '[REDACTED]' } },
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
const apiResponse = await fetch(apiUrl, {
|
| 75 |
+
method: 'POST',
|
| 76 |
+
headers: {
|
| 77 |
+
'Authorization': `Bearer ${apiKey}`,
|
| 78 |
+
'Content-Type': 'application/json',
|
| 79 |
+
'MM-API-Source': 'TavernIntern-TTS',
|
| 80 |
+
},
|
| 81 |
+
body: JSON.stringify(requestBody),
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
if (!apiResponse.ok) {
|
| 85 |
+
let errorMessage = `HTTP ${apiResponse.status}`;
|
| 86 |
+
|
| 87 |
+
try {
|
| 88 |
+
// Try to parse JSON error response
|
| 89 |
+
/** @type {any} */
|
| 90 |
+
const errorData = await apiResponse.json();
|
| 91 |
+
console.error('MiniMax TTS API error (JSON):', errorData);
|
| 92 |
+
|
| 93 |
+
// Check for MiniMax specific error format
|
| 94 |
+
const baseResp = errorData?.base_resp;
|
| 95 |
+
if (baseResp && baseResp.status_code !== 0) {
|
| 96 |
+
if (baseResp.status_code === 1004) {
|
| 97 |
+
errorMessage = 'Authentication failed - Please check your API key and API host';
|
| 98 |
+
} else {
|
| 99 |
+
errorMessage = `API Error: ${baseResp.status_msg}`;
|
| 100 |
+
}
|
| 101 |
+
} else {
|
| 102 |
+
errorMessage = errorData.error?.message || errorData.message || errorData.detail || `HTTP ${apiResponse.status}`;
|
| 103 |
+
}
|
| 104 |
+
} catch (jsonError) {
|
| 105 |
+
// If not JSON, try to read text
|
| 106 |
+
try {
|
| 107 |
+
const errorText = await apiResponse.text();
|
| 108 |
+
console.error('MiniMax TTS API error (Text):', errorText);
|
| 109 |
+
if (errorText && errorText.length > 500) {
|
| 110 |
+
errorMessage = `HTTP ${apiResponse.status}: Response too large (${errorText.length} characters)`;
|
| 111 |
+
} else {
|
| 112 |
+
errorMessage = errorText || `HTTP ${apiResponse.status}`;
|
| 113 |
+
}
|
| 114 |
+
} catch (textError) {
|
| 115 |
+
console.error('MiniMax TTS: Failed to read error response:', textError);
|
| 116 |
+
errorMessage = `HTTP ${apiResponse.status}: Unable to read error details`;
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
console.error('MiniMax TTS API request failed:', errorMessage);
|
| 121 |
+
return response.status(500).json({ error: errorMessage });
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// Parse the response
|
| 125 |
+
/** @type {any} */
|
| 126 |
+
let responseData;
|
| 127 |
+
try {
|
| 128 |
+
responseData = await apiResponse.json();
|
| 129 |
+
console.debug('MiniMax TTS Response received');
|
| 130 |
+
} catch (jsonError) {
|
| 131 |
+
console.error('MiniMax TTS: Failed to parse response as JSON:', jsonError);
|
| 132 |
+
return response.status(500).json({ error: 'Invalid response format from MiniMax API' });
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
// Check for API error codes in response data
|
| 136 |
+
const baseResp = responseData?.base_resp;
|
| 137 |
+
if (baseResp && baseResp.status_code !== 0) {
|
| 138 |
+
let errorMessage;
|
| 139 |
+
if (baseResp.status_code === 1004) {
|
| 140 |
+
errorMessage = 'Authentication failed - Please check your API key and API host';
|
| 141 |
+
} else {
|
| 142 |
+
errorMessage = `API Error: ${baseResp.status_msg}`;
|
| 143 |
+
}
|
| 144 |
+
console.error('MiniMax TTS API error:', baseResp);
|
| 145 |
+
return response.status(500).json({ error: errorMessage });
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
// Process the audio data
|
| 149 |
+
if (responseData.data && responseData.data.audio) {
|
| 150 |
+
// Process hex-encoded audio data
|
| 151 |
+
const hexAudio = responseData.data.audio;
|
| 152 |
+
|
| 153 |
+
if (!hexAudio || typeof hexAudio !== 'string') {
|
| 154 |
+
console.error('MiniMax TTS: Invalid audio data format');
|
| 155 |
+
return response.status(500).json({ error: 'Invalid audio data format' });
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
// Remove possible prefix and spaces
|
| 159 |
+
const cleanHex = hexAudio.replace(/^0x/, '').replace(/\s/g, '');
|
| 160 |
+
|
| 161 |
+
// Validate hex string format
|
| 162 |
+
if (!/^[0-9a-fA-F]*$/.test(cleanHex)) {
|
| 163 |
+
console.error('MiniMax TTS: Invalid hex string format');
|
| 164 |
+
return response.status(500).json({ error: 'Invalid audio data format' });
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
// Ensure hex string length is even
|
| 168 |
+
const paddedHex = cleanHex.length % 2 === 0 ? cleanHex : '0' + cleanHex;
|
| 169 |
+
|
| 170 |
+
try {
|
| 171 |
+
// Convert hex string to byte array
|
| 172 |
+
const hexMatches = paddedHex.match(/.{1,2}/g);
|
| 173 |
+
if (!hexMatches) {
|
| 174 |
+
console.error('MiniMax TTS: Failed to parse hex string');
|
| 175 |
+
return response.status(500).json({ error: 'Invalid hex string format' });
|
| 176 |
+
}
|
| 177 |
+
const audioBytes = new Uint8Array(hexMatches.map(byte => parseInt(byte, 16)));
|
| 178 |
+
|
| 179 |
+
if (audioBytes.length === 0) {
|
| 180 |
+
console.error('MiniMax TTS: Audio conversion resulted in empty array');
|
| 181 |
+
return response.status(500).json({ error: 'Audio data conversion failed' });
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
console.debug(`MiniMax TTS: Converted ${paddedHex.length} hex characters to ${audioBytes.length} bytes`);
|
| 185 |
+
|
| 186 |
+
// Set appropriate headers and send audio data
|
| 187 |
+
const mimeType = getAudioMimeType(format);
|
| 188 |
+
response.setHeader('Content-Type', mimeType);
|
| 189 |
+
response.setHeader('Content-Length', audioBytes.length);
|
| 190 |
+
|
| 191 |
+
return response.send(Buffer.from(audioBytes));
|
| 192 |
+
|
| 193 |
+
} catch (conversionError) {
|
| 194 |
+
console.error('MiniMax TTS: Audio conversion error:', conversionError);
|
| 195 |
+
return response.status(500).json({ error: `Audio data conversion failed: ${conversionError.message}` });
|
| 196 |
+
}
|
| 197 |
+
} else if (responseData.data && responseData.data.url) {
|
| 198 |
+
// Handle URL-based audio response
|
| 199 |
+
console.debug('MiniMax TTS: Received audio URL:', responseData.data.url);
|
| 200 |
+
|
| 201 |
+
try {
|
| 202 |
+
const audioResponse = await fetch(responseData.data.url);
|
| 203 |
+
if (!audioResponse.ok) {
|
| 204 |
+
console.error('MiniMax TTS: Failed to fetch audio from URL:', audioResponse.status);
|
| 205 |
+
return response.status(500).json({ error: `Failed to fetch audio from URL: ${audioResponse.status}` });
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
const audioBuffer = await audioResponse.arrayBuffer();
|
| 209 |
+
const mimeType = getAudioMimeType(format);
|
| 210 |
+
|
| 211 |
+
response.setHeader('Content-Type', mimeType);
|
| 212 |
+
response.setHeader('Content-Length', audioBuffer.byteLength);
|
| 213 |
+
|
| 214 |
+
return response.send(Buffer.from(audioBuffer));
|
| 215 |
+
} catch (urlError) {
|
| 216 |
+
console.error('MiniMax TTS: Error fetching audio from URL:', urlError);
|
| 217 |
+
return response.status(500).json({ error: `Failed to fetch audio: ${urlError.message}` });
|
| 218 |
+
}
|
| 219 |
+
} else {
|
| 220 |
+
// Handle error response
|
| 221 |
+
const errorMessage = responseData.base_resp?.status_msg || responseData.error?.message || 'Unknown error';
|
| 222 |
+
console.error('MiniMax TTS: No valid audio data in response:', responseData);
|
| 223 |
+
return response.status(500).json({ error: `API Error: ${errorMessage}` });
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
} catch (error) {
|
| 227 |
+
console.error('MiniMax TTS generation failed:', error);
|
| 228 |
+
return response.status(500).json({ error: 'Internal server error' });
|
| 229 |
+
}
|
| 230 |
+
});
|
src/endpoints/moving-ui.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import path from 'node:path';
|
| 2 |
+
import express from 'express';
|
| 3 |
+
import sanitize from 'sanitize-filename';
|
| 4 |
+
import { sync as writeFileAtomicSync } from 'write-file-atomic';
|
| 5 |
+
|
| 6 |
+
export const router = express.Router();
|
| 7 |
+
|
| 8 |
+
router.post('/save', (request, response) => {
|
| 9 |
+
if (!request.body || !request.body.name) {
|
| 10 |
+
return response.sendStatus(400);
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
const filename = path.join(request.user.directories.movingUI, sanitize(`${request.body.name}.json`));
|
| 14 |
+
writeFileAtomicSync(filename, JSON.stringify(request.body, null, 4), 'utf8');
|
| 15 |
+
|
| 16 |
+
return response.sendStatus(200);
|
| 17 |
+
});
|
src/endpoints/novelai.js
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import util from 'node:util';
|
| 2 |
+
import { Buffer } from 'node:buffer';
|
| 3 |
+
|
| 4 |
+
import fetch from 'node-fetch';
|
| 5 |
+
import express from 'express';
|
| 6 |
+
|
| 7 |
+
import { readSecret, SECRET_KEYS } from './secrets.js';
|
| 8 |
+
import { readAllChunks, extractFileFromZipBuffer, forwardFetchResponse } from '../util.js';
|
| 9 |
+
|
| 10 |
+
const API_NOVELAI = 'https://api.novelai.net';
|
| 11 |
+
const TEXT_NOVELAI = 'https://text.novelai.net';
|
| 12 |
+
const IMAGE_NOVELAI = 'https://image.novelai.net';
|
| 13 |
+
|
| 14 |
+
// Constants for skip_cfg_above_sigma (Variety+) calculation
|
| 15 |
+
const REFERENCE_PIXEL_COUNT = 1011712; // 832 * 1216 reference image size
|
| 16 |
+
const SIGMA_MAGIC_NUMBER = 19; // Base sigma multiplier for V3 and V4 models
|
| 17 |
+
const SIGMA_MAGIC_NUMBER_V4_5 = 58; // Base sigma multiplier for V4.5 models
|
| 18 |
+
|
| 19 |
+
// Ban bracket generation, plus defaults
|
| 20 |
+
const badWordsList = [
|
| 21 |
+
[3], [49356], [1431], [31715], [34387], [20765], [30702], [10691], [49333], [1266],
|
| 22 |
+
[19438], [43145], [26523], [41471], [2936], [85, 85], [49332], [7286], [1115], [24],
|
| 23 |
+
];
|
| 24 |
+
|
| 25 |
+
const eratoBadWordsList = [
|
| 26 |
+
[16067], [933, 11144], [25106, 11144], [58, 106901, 16073, 33710, 25, 109933],
|
| 27 |
+
[933, 58, 11144], [128030], [58, 30591, 33503, 17663, 100204, 25, 11144],
|
| 28 |
+
];
|
| 29 |
+
|
| 30 |
+
const hypeBotBadWordsList = [
|
| 31 |
+
[58], [60], [90], [92], [685], [1391], [1782], [2361], [3693], [4083], [4357], [4895],
|
| 32 |
+
[5512], [5974], [7131], [8183], [8351], [8762], [8964], [8973], [9063], [11208],
|
| 33 |
+
[11709], [11907], [11919], [12878], [12962], [13018], [13412], [14631], [14692],
|
| 34 |
+
[14980], [15090], [15437], [16151], [16410], [16589], [17241], [17414], [17635],
|
| 35 |
+
[17816], [17912], [18083], [18161], [18477], [19629], [19779], [19953], [20520],
|
| 36 |
+
[20598], [20662], [20740], [21476], [21737], [22133], [22241], [22345], [22935],
|
| 37 |
+
[23330], [23785], [23834], [23884], [25295], [25597], [25719], [25787], [25915],
|
| 38 |
+
[26076], [26358], [26398], [26894], [26933], [27007], [27422], [28013], [29164],
|
| 39 |
+
[29225], [29342], [29565], [29795], [30072], [30109], [30138], [30866], [31161],
|
| 40 |
+
[31478], [32092], [32239], [32509], [33116], [33250], [33761], [34171], [34758],
|
| 41 |
+
[34949], [35944], [36338], [36463], [36563], [36786], [36796], [36937], [37250],
|
| 42 |
+
[37913], [37981], [38165], [38362], [38381], [38430], [38892], [39850], [39893],
|
| 43 |
+
[41832], [41888], [42535], [42669], [42785], [42924], [43839], [44438], [44587],
|
| 44 |
+
[44926], [45144], [45297], [46110], [46570], [46581], [46956], [47175], [47182],
|
| 45 |
+
[47527], [47715], [48600], [48683], [48688], [48874], [48999], [49074], [49082],
|
| 46 |
+
[49146], [49946], [10221], [4841], [1427], [2602, 834], [29343], [37405], [35780], [2602], [50256],
|
| 47 |
+
];
|
| 48 |
+
|
| 49 |
+
// Used for phrase repetition penalty
|
| 50 |
+
const repPenaltyAllowList = [
|
| 51 |
+
[49256, 49264, 49231, 49230, 49287, 85, 49255, 49399, 49262, 336, 333, 432, 363, 468, 492, 745, 401, 426, 623, 794,
|
| 52 |
+
1096, 2919, 2072, 7379, 1259, 2110, 620, 526, 487, 16562, 603, 805, 761, 2681, 942, 8917, 653, 3513, 506, 5301,
|
| 53 |
+
562, 5010, 614, 10942, 539, 2976, 462, 5189, 567, 2032, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 588,
|
| 54 |
+
803, 1040, 49209, 4, 5, 6, 7, 8, 9, 10, 11, 12],
|
| 55 |
+
];
|
| 56 |
+
|
| 57 |
+
const eratoRepPenWhitelist = [
|
| 58 |
+
6, 1, 11, 13, 25, 198, 12, 9, 8, 279, 264, 459, 323, 477, 539, 912, 374, 574, 1051, 1550, 1587, 4536, 5828, 15058,
|
| 59 |
+
3287, 3250, 1461, 1077, 813, 11074, 872, 1202, 1436, 7846, 1288, 13434, 1053, 8434, 617, 9167, 1047, 19117, 706,
|
| 60 |
+
12775, 649, 4250, 527, 7784, 690, 2834, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 1210, 1359, 608, 220, 596, 956,
|
| 61 |
+
3077, 44886, 4265, 3358, 2351, 2846, 311, 389, 315, 304, 520, 505, 430,
|
| 62 |
+
];
|
| 63 |
+
|
| 64 |
+
// Ban the dinkus and asterism
|
| 65 |
+
const logitBiasExp = [
|
| 66 |
+
{ 'sequence': [23], 'bias': -0.08, 'ensure_sequence_finish': false, 'generate_once': false },
|
| 67 |
+
{ 'sequence': [21], 'bias': -0.08, 'ensure_sequence_finish': false, 'generate_once': false },
|
| 68 |
+
];
|
| 69 |
+
|
| 70 |
+
const eratoLogitBiasExp = [
|
| 71 |
+
{ 'sequence': [12488], 'bias': -0.08, 'ensure_sequence_finish': false, 'generate_once': false },
|
| 72 |
+
{ 'sequence': [128041], 'bias': -0.08, 'ensure_sequence_finish': false, 'generate_once': false },
|
| 73 |
+
];
|
| 74 |
+
|
| 75 |
+
function getBadWordsList(model) {
|
| 76 |
+
let list = [];
|
| 77 |
+
|
| 78 |
+
if (model.includes('hypebot')) {
|
| 79 |
+
list = hypeBotBadWordsList;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
if (model.includes('clio') || model.includes('kayra')) {
|
| 83 |
+
list = badWordsList;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
if (model.includes('erato')) {
|
| 87 |
+
list = eratoBadWordsList;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// Clone the list so we don't modify the original
|
| 91 |
+
return list.slice();
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
function getLogitBiasList(model) {
|
| 95 |
+
let list = [];
|
| 96 |
+
|
| 97 |
+
if (model.includes('erato')) {
|
| 98 |
+
list = eratoLogitBiasExp;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
if (model.includes('clio') || model.includes('kayra')) {
|
| 102 |
+
list = logitBiasExp;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
return list.slice();
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
function getRepPenaltyWhitelist(model) {
|
| 109 |
+
if (model.includes('clio') || model.includes('kayra')) {
|
| 110 |
+
return repPenaltyAllowList.flat();
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
if (model.includes('erato')) {
|
| 114 |
+
return eratoRepPenWhitelist.flat();
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
return null;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
function calculateSkipCfgAboveSigma(width, height, modelName) {
|
| 121 |
+
const magicConstant = modelName?.includes('nai-diffusion-4-5')
|
| 122 |
+
? SIGMA_MAGIC_NUMBER_V4_5
|
| 123 |
+
: SIGMA_MAGIC_NUMBER;
|
| 124 |
+
|
| 125 |
+
const pixelCount = width * height;
|
| 126 |
+
const ratio = pixelCount / REFERENCE_PIXEL_COUNT;
|
| 127 |
+
|
| 128 |
+
return Math.pow(ratio, 0.5) * magicConstant;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
export const router = express.Router();
|
| 132 |
+
|
| 133 |
+
router.post('/status', async function (req, res) {
|
| 134 |
+
if (!req.body) return res.sendStatus(400);
|
| 135 |
+
const api_key_novel = readSecret(req.user.directories, SECRET_KEYS.NOVEL);
|
| 136 |
+
|
| 137 |
+
if (!api_key_novel) {
|
| 138 |
+
console.warn('NovelAI Access Token is missing.');
|
| 139 |
+
return res.sendStatus(400);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
try {
|
| 143 |
+
const response = await fetch(API_NOVELAI + '/user/subscription', {
|
| 144 |
+
method: 'GET',
|
| 145 |
+
headers: {
|
| 146 |
+
'Content-Type': 'application/json',
|
| 147 |
+
'Authorization': 'Bearer ' + api_key_novel,
|
| 148 |
+
},
|
| 149 |
+
});
|
| 150 |
+
|
| 151 |
+
if (response.ok) {
|
| 152 |
+
const data = await response.json();
|
| 153 |
+
return res.send(data);
|
| 154 |
+
} else if (response.status == 401) {
|
| 155 |
+
console.error('NovelAI Access Token is incorrect.');
|
| 156 |
+
return res.send({ error: true });
|
| 157 |
+
}
|
| 158 |
+
else {
|
| 159 |
+
console.warn('NovelAI returned an error:', response.statusText);
|
| 160 |
+
return res.send({ error: true });
|
| 161 |
+
}
|
| 162 |
+
} catch (error) {
|
| 163 |
+
console.error(error);
|
| 164 |
+
return res.send({ error: true });
|
| 165 |
+
}
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
+
router.post('/generate', async function (req, res) {
|
| 169 |
+
if (!req.body) return res.sendStatus(400);
|
| 170 |
+
|
| 171 |
+
const api_key_novel = readSecret(req.user.directories, SECRET_KEYS.NOVEL);
|
| 172 |
+
|
| 173 |
+
if (!api_key_novel) {
|
| 174 |
+
console.warn('NovelAI Access Token is missing.');
|
| 175 |
+
return res.sendStatus(400);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
const controller = new AbortController();
|
| 179 |
+
req.socket.removeAllListeners('close');
|
| 180 |
+
req.socket.on('close', function () {
|
| 181 |
+
controller.abort();
|
| 182 |
+
});
|
| 183 |
+
|
| 184 |
+
// Add customized bad words for Clio, Kayra, and Erato
|
| 185 |
+
const badWordsList = getBadWordsList(req.body.model);
|
| 186 |
+
|
| 187 |
+
if (Array.isArray(badWordsList) && Array.isArray(req.body.bad_words_ids)) {
|
| 188 |
+
for (const badWord of req.body.bad_words_ids) {
|
| 189 |
+
if (Array.isArray(badWord) && badWord.every(x => Number.isInteger(x))) {
|
| 190 |
+
badWordsList.push(badWord);
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
// Remove empty arrays from bad words list
|
| 196 |
+
for (const badWord of badWordsList) {
|
| 197 |
+
if (badWord.length === 0) {
|
| 198 |
+
badWordsList.splice(badWordsList.indexOf(badWord), 1);
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
// Add default biases for dinkus and asterism
|
| 203 |
+
const logitBiasList = getLogitBiasList(req.body.model);
|
| 204 |
+
|
| 205 |
+
if (Array.isArray(logitBiasList) && Array.isArray(req.body.logit_bias_exp)) {
|
| 206 |
+
logitBiasList.push(...req.body.logit_bias_exp);
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
const repPenWhitelist = getRepPenaltyWhitelist(req.body.model);
|
| 210 |
+
|
| 211 |
+
const data = {
|
| 212 |
+
'input': req.body.input,
|
| 213 |
+
'model': req.body.model,
|
| 214 |
+
'parameters': {
|
| 215 |
+
'use_string': req.body.use_string ?? true,
|
| 216 |
+
'temperature': req.body.temperature,
|
| 217 |
+
'max_length': req.body.max_length,
|
| 218 |
+
'min_length': req.body.min_length,
|
| 219 |
+
'tail_free_sampling': req.body.tail_free_sampling,
|
| 220 |
+
'repetition_penalty': req.body.repetition_penalty,
|
| 221 |
+
'repetition_penalty_range': req.body.repetition_penalty_range,
|
| 222 |
+
'repetition_penalty_slope': req.body.repetition_penalty_slope,
|
| 223 |
+
'repetition_penalty_frequency': req.body.repetition_penalty_frequency,
|
| 224 |
+
'repetition_penalty_presence': req.body.repetition_penalty_presence,
|
| 225 |
+
'repetition_penalty_whitelist': repPenWhitelist,
|
| 226 |
+
'top_a': req.body.top_a,
|
| 227 |
+
'top_p': req.body.top_p,
|
| 228 |
+
'top_k': req.body.top_k,
|
| 229 |
+
'typical_p': req.body.typical_p,
|
| 230 |
+
'mirostat_lr': req.body.mirostat_lr,
|
| 231 |
+
'mirostat_tau': req.body.mirostat_tau,
|
| 232 |
+
'phrase_rep_pen': req.body.phrase_rep_pen,
|
| 233 |
+
'stop_sequences': req.body.stop_sequences,
|
| 234 |
+
'bad_words_ids': badWordsList.length ? badWordsList : null,
|
| 235 |
+
'logit_bias_exp': logitBiasList,
|
| 236 |
+
'generate_until_sentence': req.body.generate_until_sentence,
|
| 237 |
+
'use_cache': req.body.use_cache,
|
| 238 |
+
'return_full_text': req.body.return_full_text,
|
| 239 |
+
'prefix': req.body.prefix,
|
| 240 |
+
'order': req.body.order,
|
| 241 |
+
'num_logprobs': req.body.num_logprobs,
|
| 242 |
+
'min_p': req.body.min_p,
|
| 243 |
+
'math1_temp': req.body.math1_temp,
|
| 244 |
+
'math1_quad': req.body.math1_quad,
|
| 245 |
+
'math1_quad_entropy_scale': req.body.math1_quad_entropy_scale,
|
| 246 |
+
},
|
| 247 |
+
};
|
| 248 |
+
|
| 249 |
+
// Tells the model to stop generation at '>'
|
| 250 |
+
if ('theme_textadventure' === req.body.prefix) {
|
| 251 |
+
if (req.body.model.includes('clio') || req.body.model.includes('kayra')) {
|
| 252 |
+
data.parameters.eos_token_id = 49405;
|
| 253 |
+
}
|
| 254 |
+
if (req.body.model.includes('erato')) {
|
| 255 |
+
data.parameters.eos_token_id = 29;
|
| 256 |
+
}
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
console.debug(util.inspect(data, { depth: 4 }));
|
| 260 |
+
|
| 261 |
+
const args = {
|
| 262 |
+
body: JSON.stringify(data),
|
| 263 |
+
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + api_key_novel },
|
| 264 |
+
signal: controller.signal,
|
| 265 |
+
};
|
| 266 |
+
|
| 267 |
+
try {
|
| 268 |
+
const baseURL = (req.body.model.includes('kayra') || req.body.model.includes('erato')) ? TEXT_NOVELAI : API_NOVELAI;
|
| 269 |
+
const url = req.body.streaming ? `${baseURL}/ai/generate-stream` : `${baseURL}/ai/generate`;
|
| 270 |
+
const response = await fetch(url, { method: 'POST', ...args });
|
| 271 |
+
|
| 272 |
+
if (req.body.streaming) {
|
| 273 |
+
// Pipe remote SSE stream to Express response
|
| 274 |
+
forwardFetchResponse(response, res);
|
| 275 |
+
} else {
|
| 276 |
+
if (!response.ok) {
|
| 277 |
+
const text = await response.text();
|
| 278 |
+
let message = text;
|
| 279 |
+
console.warn(`Novel API returned error: ${response.status} ${response.statusText} ${text}`);
|
| 280 |
+
|
| 281 |
+
try {
|
| 282 |
+
const data = JSON.parse(text);
|
| 283 |
+
message = data.message;
|
| 284 |
+
}
|
| 285 |
+
catch {
|
| 286 |
+
// ignore
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
return res.status(500).send({ error: { message } });
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
/** @type {any} */
|
| 293 |
+
const data = await response.json();
|
| 294 |
+
console.info('NovelAI Output', data?.output);
|
| 295 |
+
return res.send(data);
|
| 296 |
+
}
|
| 297 |
+
} catch (error) {
|
| 298 |
+
return res.send({ error: true });
|
| 299 |
+
}
|
| 300 |
+
});
|
| 301 |
+
|
| 302 |
+
router.post('/generate-image', async (request, response) => {
|
| 303 |
+
if (!request.body) {
|
| 304 |
+
return response.sendStatus(400);
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.NOVEL);
|
| 308 |
+
|
| 309 |
+
if (!key) {
|
| 310 |
+
console.warn('NovelAI Access Token is missing.');
|
| 311 |
+
return response.sendStatus(400);
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
try {
|
| 315 |
+
console.debug('NAI Diffusion request:', request.body);
|
| 316 |
+
const generateUrl = `${IMAGE_NOVELAI}/ai/generate-image`;
|
| 317 |
+
const generateResult = await fetch(generateUrl, {
|
| 318 |
+
method: 'POST',
|
| 319 |
+
headers: {
|
| 320 |
+
'Authorization': `Bearer ${key}`,
|
| 321 |
+
'Content-Type': 'application/json',
|
| 322 |
+
},
|
| 323 |
+
body: JSON.stringify({
|
| 324 |
+
action: 'generate',
|
| 325 |
+
input: request.body.prompt ?? '',
|
| 326 |
+
model: request.body.model ?? 'nai-diffusion',
|
| 327 |
+
parameters: {
|
| 328 |
+
params_version: 3,
|
| 329 |
+
prefer_brownian: true,
|
| 330 |
+
negative_prompt: request.body.negative_prompt ?? '',
|
| 331 |
+
height: request.body.height ?? 512,
|
| 332 |
+
width: request.body.width ?? 512,
|
| 333 |
+
scale: request.body.scale ?? 9,
|
| 334 |
+
seed: request.body.seed >= 0 ? request.body.seed : Math.floor(Math.random() * 9999999999),
|
| 335 |
+
sampler: request.body.sampler ?? 'k_dpmpp_2m',
|
| 336 |
+
noise_schedule: request.body.scheduler ?? 'karras',
|
| 337 |
+
steps: request.body.steps ?? 28,
|
| 338 |
+
n_samples: 1,
|
| 339 |
+
// NAI handholding for prompts
|
| 340 |
+
ucPreset: 0,
|
| 341 |
+
qualityToggle: false,
|
| 342 |
+
add_original_image: false,
|
| 343 |
+
controlnet_strength: 1,
|
| 344 |
+
deliberate_euler_ancestral_bug: false,
|
| 345 |
+
dynamic_thresholding: request.body.decrisper ?? false,
|
| 346 |
+
legacy: false,
|
| 347 |
+
legacy_v3_extend: false,
|
| 348 |
+
sm: request.body.sm ?? false,
|
| 349 |
+
sm_dyn: request.body.sm_dyn ?? false,
|
| 350 |
+
uncond_scale: 1,
|
| 351 |
+
skip_cfg_above_sigma: request.body.variety_boost
|
| 352 |
+
? calculateSkipCfgAboveSigma(
|
| 353 |
+
request.body.width ?? 512,
|
| 354 |
+
request.body.height ?? 512,
|
| 355 |
+
request.body.model ?? 'nai-diffusion',
|
| 356 |
+
)
|
| 357 |
+
: null,
|
| 358 |
+
use_coords: false,
|
| 359 |
+
characterPrompts: [],
|
| 360 |
+
reference_image_multiple: [],
|
| 361 |
+
reference_information_extracted_multiple: [],
|
| 362 |
+
reference_strength_multiple: [],
|
| 363 |
+
v4_negative_prompt: {
|
| 364 |
+
caption: {
|
| 365 |
+
base_caption: request.body.negative_prompt ?? '',
|
| 366 |
+
char_captions: [],
|
| 367 |
+
},
|
| 368 |
+
},
|
| 369 |
+
v4_prompt: {
|
| 370 |
+
caption: {
|
| 371 |
+
base_caption: request.body.prompt ?? '',
|
| 372 |
+
char_captions: [],
|
| 373 |
+
},
|
| 374 |
+
use_coords: false,
|
| 375 |
+
use_order: true,
|
| 376 |
+
},
|
| 377 |
+
},
|
| 378 |
+
}),
|
| 379 |
+
});
|
| 380 |
+
|
| 381 |
+
if (!generateResult.ok) {
|
| 382 |
+
const text = await generateResult.text();
|
| 383 |
+
console.warn('NovelAI returned an error.', generateResult.statusText, text);
|
| 384 |
+
return response.sendStatus(500);
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
const archiveBuffer = await generateResult.arrayBuffer();
|
| 388 |
+
const imageBuffer = await extractFileFromZipBuffer(archiveBuffer, '.png');
|
| 389 |
+
|
| 390 |
+
if (!imageBuffer) {
|
| 391 |
+
console.error('NovelAI generated an image, but the PNG file was not found.');
|
| 392 |
+
return response.sendStatus(500);
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
const originalBase64 = imageBuffer.toString('base64');
|
| 396 |
+
|
| 397 |
+
// No upscaling
|
| 398 |
+
if (isNaN(request.body.upscale_ratio) || request.body.upscale_ratio <= 1) {
|
| 399 |
+
return response.send(originalBase64);
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
try {
|
| 403 |
+
console.info('Upscaling image...');
|
| 404 |
+
const upscaleUrl = `${API_NOVELAI}/ai/upscale`;
|
| 405 |
+
const upscaleResult = await fetch(upscaleUrl, {
|
| 406 |
+
method: 'POST',
|
| 407 |
+
headers: {
|
| 408 |
+
'Authorization': `Bearer ${key}`,
|
| 409 |
+
'Content-Type': 'application/json',
|
| 410 |
+
},
|
| 411 |
+
body: JSON.stringify({
|
| 412 |
+
image: originalBase64,
|
| 413 |
+
height: request.body.height,
|
| 414 |
+
width: request.body.width,
|
| 415 |
+
scale: request.body.upscale_ratio,
|
| 416 |
+
}),
|
| 417 |
+
});
|
| 418 |
+
|
| 419 |
+
if (!upscaleResult.ok) {
|
| 420 |
+
const text = await upscaleResult.text();
|
| 421 |
+
throw new Error('NovelAI returned an error.', { cause: text });
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
const upscaledArchiveBuffer = await upscaleResult.arrayBuffer();
|
| 425 |
+
const upscaledImageBuffer = await extractFileFromZipBuffer(upscaledArchiveBuffer, '.png');
|
| 426 |
+
|
| 427 |
+
if (!upscaledImageBuffer) {
|
| 428 |
+
throw new Error('NovelAI upscaled an image, but the PNG file was not found.');
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
const upscaledBase64 = upscaledImageBuffer.toString('base64');
|
| 432 |
+
|
| 433 |
+
return response.send(upscaledBase64);
|
| 434 |
+
} catch (error) {
|
| 435 |
+
console.warn('NovelAI generated an image, but upscaling failed. Returning original image.', error);
|
| 436 |
+
return response.send(originalBase64);
|
| 437 |
+
}
|
| 438 |
+
} catch (error) {
|
| 439 |
+
console.error(error);
|
| 440 |
+
return response.sendStatus(500);
|
| 441 |
+
}
|
| 442 |
+
});
|
| 443 |
+
|
| 444 |
+
router.post('/generate-voice', async (request, response) => {
|
| 445 |
+
const token = readSecret(request.user.directories, SECRET_KEYS.NOVEL);
|
| 446 |
+
|
| 447 |
+
if (!token) {
|
| 448 |
+
console.error('NovelAI Access Token is missing.');
|
| 449 |
+
return response.sendStatus(400);
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
const text = request.body.text;
|
| 453 |
+
const voice = request.body.voice;
|
| 454 |
+
|
| 455 |
+
if (!text || !voice) {
|
| 456 |
+
return response.sendStatus(400);
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
try {
|
| 460 |
+
const url = `${API_NOVELAI}/ai/generate-voice?text=${encodeURIComponent(text)}&voice=-1&seed=${encodeURIComponent(voice)}&opus=false&version=v2`;
|
| 461 |
+
const result = await fetch(url, {
|
| 462 |
+
method: 'GET',
|
| 463 |
+
headers: {
|
| 464 |
+
'Authorization': `Bearer ${token}`,
|
| 465 |
+
'Accept': 'audio/mpeg',
|
| 466 |
+
},
|
| 467 |
+
});
|
| 468 |
+
|
| 469 |
+
if (!result.ok) {
|
| 470 |
+
const errorText = await result.text();
|
| 471 |
+
console.error('NovelAI returned an error.', result.statusText, errorText);
|
| 472 |
+
return response.sendStatus(500);
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
const chunks = await readAllChunks(result.body);
|
| 476 |
+
const buffer = Buffer.concat(chunks.map(chunk => new Uint8Array(chunk)));
|
| 477 |
+
response.setHeader('Content-Type', 'audio/mpeg');
|
| 478 |
+
return response.send(buffer);
|
| 479 |
+
}
|
| 480 |
+
catch (error) {
|
| 481 |
+
console.error(error);
|
| 482 |
+
return response.sendStatus(500);
|
| 483 |
+
}
|
| 484 |
+
});
|
src/endpoints/openai.js
ADDED
|
@@ -0,0 +1,799 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from 'node:fs';
|
| 2 |
+
import { Buffer } from 'node:buffer';
|
| 3 |
+
|
| 4 |
+
import fetch from 'node-fetch';
|
| 5 |
+
import FormData from 'form-data';
|
| 6 |
+
import express from 'express';
|
| 7 |
+
|
| 8 |
+
import { getConfigValue, mergeObjectWithYaml, excludeKeysByYaml, trimV1, delay } from '../util.js';
|
| 9 |
+
import { setAdditionalHeaders } from '../additional-headers.js';
|
| 10 |
+
import { readSecret, SECRET_KEYS } from './secrets.js';
|
| 11 |
+
import { AIMLAPI_HEADERS, OPENROUTER_HEADERS, ZAI_ENDPOINT } from '../constants.js';
|
| 12 |
+
|
| 13 |
+
export const router = express.Router();
|
| 14 |
+
|
| 15 |
+
router.post('/caption-image', async (request, response) => {
|
| 16 |
+
try {
|
| 17 |
+
let key = '';
|
| 18 |
+
let headers = {};
|
| 19 |
+
let bodyParams = {};
|
| 20 |
+
|
| 21 |
+
if (request.body.api === 'openai' && !request.body.reverse_proxy) {
|
| 22 |
+
key = readSecret(request.user.directories, SECRET_KEYS.OPENAI);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
if (request.body.api === 'xai' && !request.body.reverse_proxy) {
|
| 26 |
+
key = readSecret(request.user.directories, SECRET_KEYS.XAI);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
if (request.body.api === 'mistral' && !request.body.reverse_proxy) {
|
| 30 |
+
key = readSecret(request.user.directories, SECRET_KEYS.MISTRALAI);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
if (request.body.reverse_proxy && request.body.proxy_password) {
|
| 34 |
+
key = request.body.proxy_password;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
if (request.body.api === 'custom') {
|
| 38 |
+
key = readSecret(request.user.directories, SECRET_KEYS.CUSTOM);
|
| 39 |
+
mergeObjectWithYaml(bodyParams, request.body.custom_include_body);
|
| 40 |
+
mergeObjectWithYaml(headers, request.body.custom_include_headers);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
if (request.body.api === 'openrouter') {
|
| 44 |
+
key = readSecret(request.user.directories, SECRET_KEYS.OPENROUTER);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
if (request.body.api === 'ooba') {
|
| 48 |
+
key = readSecret(request.user.directories, SECRET_KEYS.OOBA);
|
| 49 |
+
bodyParams.temperature = 0.1;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
if (request.body.api === 'koboldcpp') {
|
| 53 |
+
key = readSecret(request.user.directories, SECRET_KEYS.KOBOLDCPP);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
if (request.body.api === 'llamacpp') {
|
| 57 |
+
key = readSecret(request.user.directories, SECRET_KEYS.LLAMACPP);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
if (request.body.api === 'vllm') {
|
| 61 |
+
key = readSecret(request.user.directories, SECRET_KEYS.VLLM);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
if (request.body.api === 'aimlapi') {
|
| 65 |
+
key = readSecret(request.user.directories, SECRET_KEYS.AIMLAPI);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
if (request.body.api === 'groq') {
|
| 69 |
+
key = readSecret(request.user.directories, SECRET_KEYS.GROQ);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
if (request.body.api === 'cohere') {
|
| 73 |
+
key = readSecret(request.user.directories, SECRET_KEYS.COHERE);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
if (request.body.api === 'moonshot') {
|
| 77 |
+
key = readSecret(request.user.directories, SECRET_KEYS.MOONSHOT);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
if (request.body.api === 'nanogpt') {
|
| 81 |
+
key = readSecret(request.user.directories, SECRET_KEYS.NANOGPT);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
if (request.body.api === 'chutes') {
|
| 85 |
+
key = readSecret(request.user.directories, SECRET_KEYS.CHUTES);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
if (request.body.api === 'electronhub') {
|
| 89 |
+
key = readSecret(request.user.directories, SECRET_KEYS.ELECTRONHUB);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
if (request.body.api === 'zai') {
|
| 93 |
+
key = readSecret(request.user.directories, SECRET_KEYS.ZAI);
|
| 94 |
+
bodyParams.max_tokens = 4096; // default is 1024
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
const noKeyTypes = ['custom', 'ooba', 'koboldcpp', 'vllm', 'llamacpp', 'pollinations'];
|
| 98 |
+
if (!key && !request.body.reverse_proxy && !noKeyTypes.includes(request.body.api)) {
|
| 99 |
+
console.warn('No key found for API', request.body.api);
|
| 100 |
+
return response.sendStatus(400);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
const body = {
|
| 104 |
+
model: request.body.model,
|
| 105 |
+
messages: [
|
| 106 |
+
{
|
| 107 |
+
role: 'user',
|
| 108 |
+
content: [
|
| 109 |
+
{ type: 'text', text: request.body.prompt },
|
| 110 |
+
{ type: 'image_url', image_url: { 'url': request.body.image } },
|
| 111 |
+
],
|
| 112 |
+
},
|
| 113 |
+
],
|
| 114 |
+
...bodyParams,
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
const captionSystemPrompt = getConfigValue('openai.captionSystemPrompt');
|
| 118 |
+
if (captionSystemPrompt) {
|
| 119 |
+
body.messages.unshift({
|
| 120 |
+
role: 'system',
|
| 121 |
+
content: captionSystemPrompt,
|
| 122 |
+
});
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
if (request.body.api === 'custom') {
|
| 126 |
+
excludeKeysByYaml(body, request.body.custom_exclude_body);
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
let apiUrl = '';
|
| 130 |
+
|
| 131 |
+
if (request.body.api === 'openrouter') {
|
| 132 |
+
apiUrl = 'https://openrouter.ai/api/v1/chat/completions';
|
| 133 |
+
Object.assign(headers, OPENROUTER_HEADERS);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
if (request.body.api === 'openai') {
|
| 137 |
+
apiUrl = 'https://api.openai.com/v1/chat/completions';
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
if (request.body.reverse_proxy) {
|
| 141 |
+
apiUrl = `${request.body.reverse_proxy}/chat/completions`;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
if (request.body.api === 'custom') {
|
| 145 |
+
apiUrl = `${request.body.server_url}/chat/completions`;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
if (request.body.api === 'aimlapi') {
|
| 149 |
+
apiUrl = 'https://api.aimlapi.com/v1/chat/completions';
|
| 150 |
+
Object.assign(headers, AIMLAPI_HEADERS);
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
if (request.body.api === 'groq') {
|
| 154 |
+
apiUrl = 'https://api.groq.com/openai/v1/chat/completions';
|
| 155 |
+
if (body.messages?.[0]?.role === 'system') {
|
| 156 |
+
body.messages[0].role = 'user';
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
if (request.body.api === 'mistral') {
|
| 161 |
+
apiUrl = 'https://api.mistral.ai/v1/chat/completions';
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
if (request.body.api === 'cohere') {
|
| 165 |
+
apiUrl = 'https://api.cohere.ai/v2/chat';
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
if (request.body.api === 'xai') {
|
| 169 |
+
apiUrl = 'https://api.x.ai/v1/chat/completions';
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
if (request.body.api === 'pollinations') {
|
| 173 |
+
headers = { Authorization: '' };
|
| 174 |
+
apiUrl = 'https://text.pollinations.ai/openai/chat/completions';
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
if (request.body.api === 'moonshot') {
|
| 178 |
+
apiUrl = 'https://api.moonshot.ai/v1/chat/completions';
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
if (request.body.api === 'nanogpt') {
|
| 182 |
+
apiUrl = 'https://nano-gpt.com/api/v1/chat/completions';
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
if (request.body.api === 'chutes') {
|
| 186 |
+
apiUrl = 'https://llm.chutes.ai/v1/chat/completions';
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
if (request.body.api === 'electronhub') {
|
| 190 |
+
apiUrl = 'https://api.electronhub.ai/v1/chat/completions';
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
if (request.body.api === 'zai') {
|
| 194 |
+
apiUrl = request.body.zai_endpoint === ZAI_ENDPOINT.CODING
|
| 195 |
+
? 'https://api.z.ai/api/coding/paas/v4/chat/completions'
|
| 196 |
+
: 'https://api.z.ai/api/paas/v4/chat/completions';
|
| 197 |
+
|
| 198 |
+
// Handle video inlining for Z.AI
|
| 199 |
+
if (/data:video\/\w+;base64,/.test(request.body.image)) {
|
| 200 |
+
const message = body.messages.find(msg => Array.isArray(msg.content));
|
| 201 |
+
if (message) {
|
| 202 |
+
const imgContent = message.content.find(c => c.type === 'image_url');
|
| 203 |
+
if (imgContent) {
|
| 204 |
+
imgContent.type = 'video_url';
|
| 205 |
+
imgContent.video_url = imgContent.image_url;
|
| 206 |
+
delete imgContent.image_url;
|
| 207 |
+
}
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
if (['koboldcpp', 'vllm', 'llamacpp', 'ooba'].includes(request.body.api)) {
|
| 213 |
+
apiUrl = `${trimV1(request.body.server_url)}/v1/chat/completions`;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
if (request.body.api === 'ooba') {
|
| 217 |
+
const imgMessage = body.messages.pop();
|
| 218 |
+
body.messages.push({
|
| 219 |
+
role: 'user',
|
| 220 |
+
content: imgMessage?.content?.[0]?.text,
|
| 221 |
+
});
|
| 222 |
+
body.messages.push({
|
| 223 |
+
role: 'user',
|
| 224 |
+
content: [],
|
| 225 |
+
image_url: imgMessage?.content?.[1]?.image_url?.url,
|
| 226 |
+
});
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
setAdditionalHeaders(request, { headers }, apiUrl);
|
| 230 |
+
console.debug('Multimodal captioning request', body);
|
| 231 |
+
|
| 232 |
+
const result = await fetch(apiUrl, {
|
| 233 |
+
method: 'POST',
|
| 234 |
+
headers: {
|
| 235 |
+
'Content-Type': 'application/json',
|
| 236 |
+
Authorization: `Bearer ${key}`,
|
| 237 |
+
...headers,
|
| 238 |
+
},
|
| 239 |
+
body: JSON.stringify(body),
|
| 240 |
+
});
|
| 241 |
+
|
| 242 |
+
if (!result.ok) {
|
| 243 |
+
const text = await result.text();
|
| 244 |
+
console.warn('Multimodal captioning request failed', result.statusText, text);
|
| 245 |
+
return response.status(500).send(text);
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
/** @type {any} */
|
| 249 |
+
const data = await result.json();
|
| 250 |
+
console.info('Multimodal captioning response', data);
|
| 251 |
+
const caption = data?.choices?.[0]?.message?.content ?? data?.message?.content?.[0]?.text;
|
| 252 |
+
|
| 253 |
+
if (!caption) {
|
| 254 |
+
return response.status(500).send('No caption found');
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
return response.json({ caption });
|
| 258 |
+
}
|
| 259 |
+
catch (error) {
|
| 260 |
+
console.error(error);
|
| 261 |
+
response.status(500).send('Internal server error');
|
| 262 |
+
}
|
| 263 |
+
});
|
| 264 |
+
|
| 265 |
+
router.post('/generate-voice', async (request, response) => {
|
| 266 |
+
try {
|
| 267 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.OPENAI);
|
| 268 |
+
|
| 269 |
+
if (!key) {
|
| 270 |
+
console.warn('No OpenAI key found');
|
| 271 |
+
return response.sendStatus(400);
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
const requestBody = {
|
| 275 |
+
input: request.body.text,
|
| 276 |
+
response_format: 'mp3',
|
| 277 |
+
voice: request.body.voice ?? 'alloy',
|
| 278 |
+
speed: request.body.speed ?? 1,
|
| 279 |
+
model: request.body.model ?? 'tts-1',
|
| 280 |
+
};
|
| 281 |
+
|
| 282 |
+
if (request.body.instructions) {
|
| 283 |
+
requestBody.instructions = request.body.instructions;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
console.debug('OpenAI TTS request', requestBody);
|
| 287 |
+
|
| 288 |
+
const result = await fetch('https://api.openai.com/v1/audio/speech', {
|
| 289 |
+
method: 'POST',
|
| 290 |
+
headers: {
|
| 291 |
+
'Content-Type': 'application/json',
|
| 292 |
+
Authorization: `Bearer ${key}`,
|
| 293 |
+
},
|
| 294 |
+
body: JSON.stringify(requestBody),
|
| 295 |
+
});
|
| 296 |
+
|
| 297 |
+
if (!result.ok) {
|
| 298 |
+
const text = await result.text();
|
| 299 |
+
console.warn('OpenAI request failed', result.statusText, text);
|
| 300 |
+
return response.status(500).send(text);
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
const buffer = await result.arrayBuffer();
|
| 304 |
+
response.setHeader('Content-Type', 'audio/mpeg');
|
| 305 |
+
return response.send(Buffer.from(buffer));
|
| 306 |
+
} catch (error) {
|
| 307 |
+
console.error('OpenAI TTS generation failed', error);
|
| 308 |
+
response.status(500).send('Internal server error');
|
| 309 |
+
}
|
| 310 |
+
});
|
| 311 |
+
|
| 312 |
+
// ElectronHub TTS proxy
|
| 313 |
+
router.post('/electronhub/generate-voice', async (request, response) => {
|
| 314 |
+
try {
|
| 315 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.ELECTRONHUB);
|
| 316 |
+
|
| 317 |
+
if (!key) {
|
| 318 |
+
console.warn('No ElectronHub key found');
|
| 319 |
+
return response.sendStatus(400);
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
const requestBody = {
|
| 323 |
+
input: request.body.input,
|
| 324 |
+
voice: request.body.voice,
|
| 325 |
+
speed: request.body.speed ?? 1,
|
| 326 |
+
temperature: request.body.temperature ?? undefined,
|
| 327 |
+
model: request.body.model || 'tts-1',
|
| 328 |
+
response_format: 'mp3',
|
| 329 |
+
};
|
| 330 |
+
|
| 331 |
+
// Optional provider-specific params
|
| 332 |
+
if (request.body.instructions) requestBody.instructions = request.body.instructions;
|
| 333 |
+
if (request.body.speaker_transcript) requestBody.speaker_transcript = request.body.speaker_transcript;
|
| 334 |
+
if (Number.isFinite(request.body.cfg_scale)) requestBody.cfg_scale = Number(request.body.cfg_scale);
|
| 335 |
+
if (Number.isFinite(request.body.cfg_filter_top_k)) requestBody.cfg_filter_top_k = Number(request.body.cfg_filter_top_k);
|
| 336 |
+
if (Number.isFinite(request.body.speech_rate)) requestBody.speech_rate = Number(request.body.speech_rate);
|
| 337 |
+
if (Number.isFinite(request.body.pitch_adjustment)) requestBody.pitch_adjustment = Number(request.body.pitch_adjustment);
|
| 338 |
+
if (request.body.emotional_style) requestBody.emotional_style = request.body.emotional_style;
|
| 339 |
+
|
| 340 |
+
// Handle dynamic parameters sent from the frontend
|
| 341 |
+
const knownParams = new Set(Object.keys(requestBody));
|
| 342 |
+
for (const key in request.body) {
|
| 343 |
+
if (!knownParams.has(key) && request.body[key] !== undefined) {
|
| 344 |
+
requestBody[key] = request.body[key];
|
| 345 |
+
}
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
// Clean undefineds
|
| 349 |
+
Object.keys(requestBody).forEach(k => requestBody[k] === undefined && delete requestBody[k]);
|
| 350 |
+
|
| 351 |
+
console.debug('ElectronHub TTS request', requestBody);
|
| 352 |
+
|
| 353 |
+
const result = await fetch('https://api.electronhub.ai/v1/audio/speech', {
|
| 354 |
+
method: 'POST',
|
| 355 |
+
headers: {
|
| 356 |
+
'Content-Type': 'application/json',
|
| 357 |
+
Authorization: `Bearer ${key}`,
|
| 358 |
+
},
|
| 359 |
+
body: JSON.stringify(requestBody),
|
| 360 |
+
});
|
| 361 |
+
|
| 362 |
+
if (!result.ok) {
|
| 363 |
+
const text = await result.text();
|
| 364 |
+
console.warn('ElectronHub TTS request failed', result.statusText, text);
|
| 365 |
+
return response.status(500).send(text);
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
const contentType = result.headers.get('content-type') || 'audio/mpeg';
|
| 369 |
+
const buffer = await result.arrayBuffer();
|
| 370 |
+
response.setHeader('Content-Type', contentType);
|
| 371 |
+
return response.send(Buffer.from(buffer));
|
| 372 |
+
} catch (error) {
|
| 373 |
+
console.error('ElectronHub TTS generation failed', error);
|
| 374 |
+
response.status(500).send('Internal server error');
|
| 375 |
+
}
|
| 376 |
+
});
|
| 377 |
+
|
| 378 |
+
// ElectronHub model list
|
| 379 |
+
router.post('/electronhub/models', async (request, response) => {
|
| 380 |
+
try {
|
| 381 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.ELECTRONHUB);
|
| 382 |
+
|
| 383 |
+
if (!key) {
|
| 384 |
+
console.warn('No ElectronHub key found');
|
| 385 |
+
return response.sendStatus(400);
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
const result = await fetch('https://api.electronhub.ai/v1/models', {
|
| 389 |
+
method: 'GET',
|
| 390 |
+
headers: {
|
| 391 |
+
Authorization: `Bearer ${key}`,
|
| 392 |
+
},
|
| 393 |
+
});
|
| 394 |
+
|
| 395 |
+
if (!result.ok) {
|
| 396 |
+
const text = await result.text();
|
| 397 |
+
console.warn('ElectronHub models request failed', result.statusText, text);
|
| 398 |
+
return response.status(500).send(text);
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
const data = await result.json();
|
| 402 |
+
const models = data && Array.isArray(data['data']) ? data['data'] : [];
|
| 403 |
+
return response.json(models);
|
| 404 |
+
} catch (error) {
|
| 405 |
+
console.error('ElectronHub models fetch failed', error);
|
| 406 |
+
response.status(500).send('Internal server error');
|
| 407 |
+
}
|
| 408 |
+
});
|
| 409 |
+
|
| 410 |
+
// Chutes TTS
|
| 411 |
+
router.post('/chutes/generate-voice', async (request, response) => {
|
| 412 |
+
try {
|
| 413 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.CHUTES);
|
| 414 |
+
|
| 415 |
+
if (!key) {
|
| 416 |
+
console.warn('No Chutes key found');
|
| 417 |
+
return response.sendStatus(400);
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
const requestBody = {
|
| 421 |
+
text: request.body.input,
|
| 422 |
+
voice: request.body.voice || 'af_heart',
|
| 423 |
+
speed: request.body.speed || 1,
|
| 424 |
+
};
|
| 425 |
+
|
| 426 |
+
console.debug('Chutes TTS request', requestBody);
|
| 427 |
+
|
| 428 |
+
const result = await fetch('https://chutes-kokoro.chutes.ai/speak', {
|
| 429 |
+
method: 'POST',
|
| 430 |
+
headers: {
|
| 431 |
+
'Content-Type': 'application/json',
|
| 432 |
+
Authorization: `Bearer ${key}`,
|
| 433 |
+
},
|
| 434 |
+
body: JSON.stringify(requestBody),
|
| 435 |
+
});
|
| 436 |
+
|
| 437 |
+
if (!result.ok) {
|
| 438 |
+
const text = await result.text();
|
| 439 |
+
console.warn('Chutes TTS request failed', result.statusText, text);
|
| 440 |
+
return response.status(500).send(text);
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
const contentType = result.headers.get('content-type') || 'audio/mpeg';
|
| 444 |
+
const buffer = await result.arrayBuffer();
|
| 445 |
+
response.setHeader('Content-Type', contentType);
|
| 446 |
+
return response.send(Buffer.from(buffer));
|
| 447 |
+
} catch (error) {
|
| 448 |
+
console.error('Chutes TTS generation failed', error);
|
| 449 |
+
response.status(500).send('Internal server error');
|
| 450 |
+
}
|
| 451 |
+
});
|
| 452 |
+
|
| 453 |
+
router.post('/chutes/models/embedding', async (request, response) => {
|
| 454 |
+
try {
|
| 455 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.CHUTES);
|
| 456 |
+
|
| 457 |
+
if (!key) {
|
| 458 |
+
console.warn('No Chutes key found');
|
| 459 |
+
return response.sendStatus(400);
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
const result = await fetch('https://api.chutes.ai/chutes/?template=embedding&include_public=true&limit=999', {
|
| 463 |
+
method: 'GET',
|
| 464 |
+
headers: {
|
| 465 |
+
Authorization: `Bearer ${key}`,
|
| 466 |
+
},
|
| 467 |
+
});
|
| 468 |
+
|
| 469 |
+
if (!result.ok) {
|
| 470 |
+
const text = await result.text();
|
| 471 |
+
console.warn('Chutes embedding models request failed', result.statusText, text);
|
| 472 |
+
return response.status(500).send(text);
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
/** @type {any} */
|
| 476 |
+
const data = await result.json();
|
| 477 |
+
|
| 478 |
+
if (!Array.isArray(data?.items)) {
|
| 479 |
+
console.warn('Chutes embedding models response invalid', data);
|
| 480 |
+
return response.sendStatus(500);
|
| 481 |
+
}
|
| 482 |
+
return response.json(data.items);
|
| 483 |
+
} catch (error) {
|
| 484 |
+
console.error('Chutes embedding models fetch failed', error);
|
| 485 |
+
response.sendStatus(500);
|
| 486 |
+
}
|
| 487 |
+
});
|
| 488 |
+
|
| 489 |
+
router.post('/generate-image', async (request, response) => {
|
| 490 |
+
try {
|
| 491 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.OPENAI);
|
| 492 |
+
|
| 493 |
+
if (!key) {
|
| 494 |
+
console.warn('No OpenAI key found');
|
| 495 |
+
return response.sendStatus(400);
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
console.debug('OpenAI request', request.body);
|
| 499 |
+
|
| 500 |
+
const result = await fetch('https://api.openai.com/v1/images/generations', {
|
| 501 |
+
method: 'POST',
|
| 502 |
+
headers: {
|
| 503 |
+
'Content-Type': 'application/json',
|
| 504 |
+
Authorization: `Bearer ${key}`,
|
| 505 |
+
},
|
| 506 |
+
body: JSON.stringify(request.body),
|
| 507 |
+
});
|
| 508 |
+
|
| 509 |
+
if (!result.ok) {
|
| 510 |
+
const text = await result.text();
|
| 511 |
+
console.warn('OpenAI request failed', result.statusText, text);
|
| 512 |
+
return response.status(500).send(text);
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
const data = await result.json();
|
| 516 |
+
return response.json(data);
|
| 517 |
+
} catch (error) {
|
| 518 |
+
console.error(error);
|
| 519 |
+
response.status(500).send('Internal server error');
|
| 520 |
+
}
|
| 521 |
+
});
|
| 522 |
+
|
| 523 |
+
router.post('/generate-video', async (request, response) => {
|
| 524 |
+
try {
|
| 525 |
+
const controller = new AbortController();
|
| 526 |
+
request.socket.removeAllListeners('close');
|
| 527 |
+
request.socket.on('close', function () {
|
| 528 |
+
controller.abort();
|
| 529 |
+
});
|
| 530 |
+
|
| 531 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.OPENAI);
|
| 532 |
+
|
| 533 |
+
if (!key) {
|
| 534 |
+
console.warn('No OpenAI key found');
|
| 535 |
+
return response.sendStatus(400);
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
console.debug('OpenAI video generation request', request.body);
|
| 539 |
+
|
| 540 |
+
const videoJobResponse = await fetch('https://api.openai.com/v1/videos', {
|
| 541 |
+
method: 'POST',
|
| 542 |
+
headers: {
|
| 543 |
+
'Content-Type': 'application/json',
|
| 544 |
+
'Authorization': `Bearer ${key}`,
|
| 545 |
+
},
|
| 546 |
+
body: JSON.stringify({
|
| 547 |
+
prompt: request.body.prompt,
|
| 548 |
+
model: request.body.model || 'sora-2',
|
| 549 |
+
size: request.body.size || '720x1280',
|
| 550 |
+
seconds: request.body.seconds || '8',
|
| 551 |
+
}),
|
| 552 |
+
});
|
| 553 |
+
|
| 554 |
+
if (!videoJobResponse.ok) {
|
| 555 |
+
const text = await videoJobResponse.text();
|
| 556 |
+
console.warn('OpenAI video generation request failed', videoJobResponse.statusText, text);
|
| 557 |
+
return response.status(500).send(text);
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
/** @type {any} */
|
| 561 |
+
const videoJob = await videoJobResponse.json();
|
| 562 |
+
|
| 563 |
+
if (!videoJob || !videoJob.id) {
|
| 564 |
+
console.warn('OpenAI video generation returned no job ID', videoJob);
|
| 565 |
+
return response.status(500).send('No video job ID returned');
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
// Poll for video generation completion
|
| 569 |
+
for (let attempt = 0; attempt < 30; attempt++) {
|
| 570 |
+
if (controller.signal.aborted) {
|
| 571 |
+
console.info('OpenAI video generation aborted by client');
|
| 572 |
+
return response.status(500).send('Video generation aborted by client');
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
await delay(5000 + attempt * 1000);
|
| 576 |
+
console.debug(`Polling OpenAI video job ${videoJob.id}, attempt ${attempt + 1}`);
|
| 577 |
+
|
| 578 |
+
const pollResponse = await fetch(`https://api.openai.com/v1/videos/${videoJob.id}`, {
|
| 579 |
+
method: 'GET',
|
| 580 |
+
headers: {
|
| 581 |
+
'Authorization': `Bearer ${key}`,
|
| 582 |
+
},
|
| 583 |
+
});
|
| 584 |
+
|
| 585 |
+
if (!pollResponse.ok) {
|
| 586 |
+
const text = await pollResponse.text();
|
| 587 |
+
console.warn('OpenAI video job polling failed', pollResponse.statusText, text);
|
| 588 |
+
return response.status(500).send(text);
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
/** @type {any} */
|
| 592 |
+
const pollResult = await pollResponse.json();
|
| 593 |
+
console.debug(`OpenAI video job status: ${pollResult.status}, progress: ${pollResult.progress}`);
|
| 594 |
+
|
| 595 |
+
if (pollResult.status === 'failed') {
|
| 596 |
+
console.warn('OpenAI video generation failed', pollResult);
|
| 597 |
+
return response.status(500).send('Video generation failed');
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
if (pollResult.status === 'completed') {
|
| 601 |
+
const contentResponse = await fetch(`https://api.openai.com/v1/videos/${videoJob.id}/content`, {
|
| 602 |
+
method: 'GET',
|
| 603 |
+
headers: {
|
| 604 |
+
'Authorization': `Bearer ${key}`,
|
| 605 |
+
},
|
| 606 |
+
});
|
| 607 |
+
|
| 608 |
+
if (!contentResponse.ok) {
|
| 609 |
+
const text = await contentResponse.text();
|
| 610 |
+
console.warn('OpenAI video content fetch failed', contentResponse.statusText, text);
|
| 611 |
+
return response.status(500).send(text);
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
const contentBuffer = await contentResponse.arrayBuffer();
|
| 615 |
+
return response.send({ format: 'mp4', data: Buffer.from(contentBuffer).toString('base64') });
|
| 616 |
+
}
|
| 617 |
+
}
|
| 618 |
+
} catch (error) {
|
| 619 |
+
console.error('OpenAI video generation failed', error);
|
| 620 |
+
response.status(500).send('Internal server error');
|
| 621 |
+
}
|
| 622 |
+
});
|
| 623 |
+
|
| 624 |
+
const custom = express.Router();
|
| 625 |
+
|
| 626 |
+
custom.post('/generate-voice', async (request, response) => {
|
| 627 |
+
try {
|
| 628 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.CUSTOM_OPENAI_TTS);
|
| 629 |
+
const { input, provider_endpoint, response_format, voice, speed, model } = request.body;
|
| 630 |
+
|
| 631 |
+
if (!provider_endpoint) {
|
| 632 |
+
console.warn('No OpenAI-compatible TTS provider endpoint provided');
|
| 633 |
+
return response.sendStatus(400);
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
const result = await fetch(provider_endpoint, {
|
| 637 |
+
method: 'POST',
|
| 638 |
+
headers: {
|
| 639 |
+
'Content-Type': 'application/json',
|
| 640 |
+
Authorization: `Bearer ${key ?? ''}`,
|
| 641 |
+
},
|
| 642 |
+
body: JSON.stringify({
|
| 643 |
+
input: input ?? '',
|
| 644 |
+
response_format: response_format ?? 'mp3',
|
| 645 |
+
voice: voice ?? 'alloy',
|
| 646 |
+
speed: speed ?? 1,
|
| 647 |
+
model: model ?? 'tts-1',
|
| 648 |
+
}),
|
| 649 |
+
});
|
| 650 |
+
|
| 651 |
+
if (!result.ok) {
|
| 652 |
+
const text = await result.text();
|
| 653 |
+
console.warn('OpenAI request failed', result.statusText, text);
|
| 654 |
+
return response.status(500).send(text);
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
const buffer = await result.arrayBuffer();
|
| 658 |
+
response.setHeader('Content-Type', 'audio/mpeg');
|
| 659 |
+
return response.send(Buffer.from(buffer));
|
| 660 |
+
} catch (error) {
|
| 661 |
+
console.error('OpenAI TTS generation failed', error);
|
| 662 |
+
response.status(500).send('Internal server error');
|
| 663 |
+
}
|
| 664 |
+
});
|
| 665 |
+
|
| 666 |
+
router.use('/custom', custom);
|
| 667 |
+
|
| 668 |
+
/**
|
| 669 |
+
* Creates a transcribe-audio endpoint handler for a given provider.
|
| 670 |
+
* @param {object} config - Provider configuration
|
| 671 |
+
* @param {string} config.secretKey - The SECRET_KEYS enum value for the provider
|
| 672 |
+
* @param {string} config.apiUrl - The transcription API endpoint URL
|
| 673 |
+
* @param {string} config.providerName - Display name for logging
|
| 674 |
+
* @returns {import('express').RequestHandler} Express request handler
|
| 675 |
+
*/
|
| 676 |
+
function createTranscribeHandler({ secretKey, apiUrl, providerName }) {
|
| 677 |
+
return async (request, response) => {
|
| 678 |
+
try {
|
| 679 |
+
const key = readSecret(request.user.directories, secretKey);
|
| 680 |
+
|
| 681 |
+
if (!key) {
|
| 682 |
+
console.warn(`No ${providerName} key found`);
|
| 683 |
+
return response.sendStatus(400);
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
if (!request.file) {
|
| 687 |
+
console.warn('No audio file found');
|
| 688 |
+
return response.sendStatus(400);
|
| 689 |
+
}
|
| 690 |
+
|
| 691 |
+
console.info(`Processing audio file with ${providerName}`, request.file.path);
|
| 692 |
+
const formData = new FormData();
|
| 693 |
+
formData.append('file', fs.createReadStream(request.file.path), { filename: 'audio.wav', contentType: 'audio/wav' });
|
| 694 |
+
formData.append('model', request.body.model);
|
| 695 |
+
|
| 696 |
+
if (request.body.language) {
|
| 697 |
+
formData.append('language', request.body.language);
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
const result = await fetch(apiUrl, {
|
| 701 |
+
method: 'POST',
|
| 702 |
+
headers: {
|
| 703 |
+
'Authorization': `Bearer ${key}`,
|
| 704 |
+
...formData.getHeaders(),
|
| 705 |
+
},
|
| 706 |
+
body: formData,
|
| 707 |
+
});
|
| 708 |
+
|
| 709 |
+
if (!result.ok) {
|
| 710 |
+
const text = await result.text();
|
| 711 |
+
console.warn(`${providerName} request failed`, result.statusText, text);
|
| 712 |
+
return response.status(500).send(text);
|
| 713 |
+
}
|
| 714 |
+
|
| 715 |
+
fs.unlinkSync(request.file.path);
|
| 716 |
+
const data = await result.json();
|
| 717 |
+
console.debug(`${providerName} transcription response`, data);
|
| 718 |
+
return response.json(data);
|
| 719 |
+
} catch (error) {
|
| 720 |
+
console.error(`${providerName} transcription failed`, error);
|
| 721 |
+
response.status(500).send('Internal server error');
|
| 722 |
+
}
|
| 723 |
+
};
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
router.post('/transcribe-audio', createTranscribeHandler({
|
| 727 |
+
secretKey: SECRET_KEYS.OPENAI,
|
| 728 |
+
apiUrl: 'https://api.openai.com/v1/audio/transcriptions',
|
| 729 |
+
providerName: 'OpenAI',
|
| 730 |
+
}));
|
| 731 |
+
|
| 732 |
+
router.post('/groq/transcribe-audio', createTranscribeHandler({
|
| 733 |
+
secretKey: SECRET_KEYS.GROQ,
|
| 734 |
+
apiUrl: 'https://api.groq.com/openai/v1/audio/transcriptions',
|
| 735 |
+
providerName: 'Groq',
|
| 736 |
+
}));
|
| 737 |
+
|
| 738 |
+
router.post('/mistral/transcribe-audio', createTranscribeHandler({
|
| 739 |
+
secretKey: SECRET_KEYS.MISTRALAI,
|
| 740 |
+
apiUrl: 'https://api.mistral.ai/v1/audio/transcriptions',
|
| 741 |
+
providerName: 'MistralAI',
|
| 742 |
+
}));
|
| 743 |
+
|
| 744 |
+
router.post('/zai/transcribe-audio', createTranscribeHandler({
|
| 745 |
+
secretKey: SECRET_KEYS.ZAI,
|
| 746 |
+
apiUrl: 'https://api.z.ai/api/paas/v4/audio/transcriptions',
|
| 747 |
+
providerName: 'Z.AI',
|
| 748 |
+
}));
|
| 749 |
+
|
| 750 |
+
router.post('/chutes/transcribe-audio', async (request, response) => {
|
| 751 |
+
try {
|
| 752 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.CHUTES);
|
| 753 |
+
|
| 754 |
+
if (!key) {
|
| 755 |
+
console.warn('No Chutes key found');
|
| 756 |
+
return response.sendStatus(400);
|
| 757 |
+
}
|
| 758 |
+
|
| 759 |
+
if (!request.file) {
|
| 760 |
+
console.warn('No audio file found');
|
| 761 |
+
return response.sendStatus(400);
|
| 762 |
+
}
|
| 763 |
+
|
| 764 |
+
console.info('Processing audio file with Chutes', request.file.path);
|
| 765 |
+
const audioBase64 = fs.readFileSync(request.file.path).toString('base64');
|
| 766 |
+
|
| 767 |
+
const result = await fetch(`https://${request.body.model}.chutes.ai/transcribe`, {
|
| 768 |
+
method: 'POST',
|
| 769 |
+
headers: {
|
| 770 |
+
'Authorization': `Bearer ${key}`,
|
| 771 |
+
'Content-Type': 'application/json',
|
| 772 |
+
},
|
| 773 |
+
body: JSON.stringify({
|
| 774 |
+
audio_b64: audioBase64,
|
| 775 |
+
}),
|
| 776 |
+
});
|
| 777 |
+
|
| 778 |
+
if (!result.ok) {
|
| 779 |
+
const text = await result.text();
|
| 780 |
+
console.warn('Chutes request failed', result.statusText, text);
|
| 781 |
+
return response.status(500).send(text);
|
| 782 |
+
}
|
| 783 |
+
|
| 784 |
+
fs.unlinkSync(request.file.path);
|
| 785 |
+
const data = await result.json();
|
| 786 |
+
console.debug('Chutes transcription response', data);
|
| 787 |
+
|
| 788 |
+
if (!Array.isArray(data)) {
|
| 789 |
+
console.warn('Chutes transcription response invalid', data);
|
| 790 |
+
return response.sendStatus(500);
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
const fullText = data.map(chunk => chunk.text || '').join('').trim();
|
| 794 |
+
return response.json({ text: fullText });
|
| 795 |
+
} catch (error) {
|
| 796 |
+
console.error('Chutes transcription failed', error);
|
| 797 |
+
response.status(500).send('Internal server error');
|
| 798 |
+
}
|
| 799 |
+
});
|
src/endpoints/openrouter.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import express from 'express';
|
| 2 |
+
import fetch from 'node-fetch';
|
| 3 |
+
import mime from 'mime-types';
|
| 4 |
+
import { readSecret, SECRET_KEYS } from './secrets.js';
|
| 5 |
+
|
| 6 |
+
export const router = express.Router();
|
| 7 |
+
const API_OPENROUTER = 'https://openrouter.ai/api/v1';
|
| 8 |
+
|
| 9 |
+
router.post('/models/providers', async (req, res) => {
|
| 10 |
+
try {
|
| 11 |
+
const { model } = req.body;
|
| 12 |
+
const response = await fetch(`${API_OPENROUTER}/models/${model}/endpoints`, {
|
| 13 |
+
method: 'GET',
|
| 14 |
+
headers: {
|
| 15 |
+
'Accept': 'application/json',
|
| 16 |
+
},
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
if (!response.ok) {
|
| 20 |
+
return res.json([]);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
/** @type {any} */
|
| 24 |
+
const data = await response.json();
|
| 25 |
+
const endpoints = data?.data?.endpoints || [];
|
| 26 |
+
const providerNames = endpoints.map(e => e.provider_name);
|
| 27 |
+
|
| 28 |
+
return res.json(providerNames);
|
| 29 |
+
} catch (error) {
|
| 30 |
+
console.error(error);
|
| 31 |
+
return res.sendStatus(500);
|
| 32 |
+
}
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
/**
|
| 36 |
+
* Fetches and filters models from OpenRouter API based on modality criteria.
|
| 37 |
+
* @param {string} endpoint - The API endpoint to fetch from
|
| 38 |
+
* @param {string} inputModality - Required input modality
|
| 39 |
+
* @param {string} outputModality - Required output modality
|
| 40 |
+
* @param {boolean} [idsOnly=false] - Whether to return only model IDs
|
| 41 |
+
* @returns {Promise<any[]>} Filtered models or model IDs
|
| 42 |
+
*/
|
| 43 |
+
async function fetchModelsByModality(endpoint, inputModality, outputModality, idsOnly = false) {
|
| 44 |
+
const response = await fetch(`${API_OPENROUTER}${endpoint}`, {
|
| 45 |
+
method: 'GET',
|
| 46 |
+
headers: { 'Accept': 'application/json' },
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
if (!response.ok) {
|
| 50 |
+
console.warn('OpenRouter API request failed', response.statusText);
|
| 51 |
+
return [];
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/** @type {any} */
|
| 55 |
+
const data = await response.json();
|
| 56 |
+
|
| 57 |
+
if (!Array.isArray(data?.data)) {
|
| 58 |
+
console.warn('OpenRouter API response was not an array');
|
| 59 |
+
return [];
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
const filtered = data.data
|
| 63 |
+
.filter(m => Array.isArray(m?.architecture?.input_modalities))
|
| 64 |
+
.filter(m => m.architecture.input_modalities.includes(inputModality))
|
| 65 |
+
.filter(m => Array.isArray(m?.architecture?.output_modalities))
|
| 66 |
+
.filter(m => m.architecture.output_modalities.includes(outputModality))
|
| 67 |
+
.sort((a, b) => a?.id && b?.id ? a.id.localeCompare(b.id) : 0);
|
| 68 |
+
|
| 69 |
+
return idsOnly ? filtered.map(m => m.id) : filtered;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
router.post('/models/multimodal', async (_req, res) => {
|
| 73 |
+
try {
|
| 74 |
+
const models = await fetchModelsByModality('/models', 'image', 'text', true);
|
| 75 |
+
return res.json(models);
|
| 76 |
+
} catch (error) {
|
| 77 |
+
console.error(error);
|
| 78 |
+
return res.sendStatus(500);
|
| 79 |
+
}
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
router.post('/models/embedding', async (_req, res) => {
|
| 83 |
+
try {
|
| 84 |
+
const models = await fetchModelsByModality('/embeddings/models', 'text', 'embeddings');
|
| 85 |
+
return res.json(models);
|
| 86 |
+
} catch (error) {
|
| 87 |
+
console.error(error);
|
| 88 |
+
return res.sendStatus(500);
|
| 89 |
+
}
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
router.post('/models/image', async (_req, res) => {
|
| 93 |
+
try {
|
| 94 |
+
const models = await fetchModelsByModality('/models', 'text', 'image');
|
| 95 |
+
return res.json(models.map(m => ({ value: m.id, text: m.name || m.id })));
|
| 96 |
+
} catch (error) {
|
| 97 |
+
console.error(error);
|
| 98 |
+
return res.sendStatus(500);
|
| 99 |
+
}
|
| 100 |
+
});
|
| 101 |
+
|
| 102 |
+
router.post('/image/generate', async (req, res) => {
|
| 103 |
+
try {
|
| 104 |
+
const key = readSecret(req.user.directories, SECRET_KEYS.OPENROUTER);
|
| 105 |
+
|
| 106 |
+
if (!key) {
|
| 107 |
+
console.warn('OpenRouter API key not found');
|
| 108 |
+
return res.status(400).json({ error: 'OpenRouter API key not found' });
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
console.debug('OpenRouter image generation request', req.body);
|
| 112 |
+
|
| 113 |
+
const { model, prompt } = req.body;
|
| 114 |
+
|
| 115 |
+
if (!model || !prompt) {
|
| 116 |
+
return res.status(400).json({ error: 'Model and prompt are required' });
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
const response = await fetch(`${API_OPENROUTER}/chat/completions`, {
|
| 120 |
+
method: 'POST',
|
| 121 |
+
headers: {
|
| 122 |
+
'Content-Type': 'application/json',
|
| 123 |
+
'Authorization': `Bearer ${key}`,
|
| 124 |
+
},
|
| 125 |
+
body: JSON.stringify({
|
| 126 |
+
model: model,
|
| 127 |
+
messages: [
|
| 128 |
+
{
|
| 129 |
+
role: 'user',
|
| 130 |
+
content: prompt,
|
| 131 |
+
},
|
| 132 |
+
],
|
| 133 |
+
modalities: ['image', 'text'],
|
| 134 |
+
image_config: {
|
| 135 |
+
aspect_ratio: req.body.aspect_ratio || '1:1',
|
| 136 |
+
},
|
| 137 |
+
}),
|
| 138 |
+
});
|
| 139 |
+
|
| 140 |
+
if (!response.ok) {
|
| 141 |
+
console.warn('OpenRouter image generation failed', await response.text());
|
| 142 |
+
return res.sendStatus(500);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
/** @type {any} */
|
| 146 |
+
const data = await response.json();
|
| 147 |
+
|
| 148 |
+
const imageUrl = data?.choices?.[0]?.message?.images?.[0]?.image_url?.url;
|
| 149 |
+
|
| 150 |
+
if (!imageUrl) {
|
| 151 |
+
console.warn('No image URL found in OpenRouter response', data);
|
| 152 |
+
return res.sendStatus(500);
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
const [mimeType, base64Data] = /^data:(.*);base64,(.*)$/.exec(imageUrl)?.slice(1) || [];
|
| 156 |
+
|
| 157 |
+
if (!mimeType || !base64Data) {
|
| 158 |
+
console.warn('Invalid image data format', imageUrl);
|
| 159 |
+
return res.sendStatus(500);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
const result = {
|
| 163 |
+
format: mime.extension(mimeType) || 'png',
|
| 164 |
+
image: base64Data,
|
| 165 |
+
};
|
| 166 |
+
|
| 167 |
+
return res.json(result);
|
| 168 |
+
} catch (error) {
|
| 169 |
+
console.error(error);
|
| 170 |
+
return res.sendStatus(500);
|
| 171 |
+
}
|
| 172 |
+
});
|
src/endpoints/presets.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from 'node:fs';
|
| 2 |
+
import path from 'node:path';
|
| 3 |
+
|
| 4 |
+
import express from 'express';
|
| 5 |
+
import sanitize from 'sanitize-filename';
|
| 6 |
+
import { sync as writeFileAtomicSync } from 'write-file-atomic';
|
| 7 |
+
|
| 8 |
+
import { getDefaultPresetFile, getDefaultPresets } from './content-manager.js';
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* Gets the folder and extension for the preset settings based on the API source ID.
|
| 12 |
+
* @param {string} apiId API source ID
|
| 13 |
+
* @param {import('../users.js').UserDirectoryList} directories User directories
|
| 14 |
+
* @returns {{folder: string?, extension: string?}} Object containing the folder and extension for the preset settings
|
| 15 |
+
*/
|
| 16 |
+
function getPresetSettingsByAPI(apiId, directories) {
|
| 17 |
+
switch (apiId) {
|
| 18 |
+
case 'kobold':
|
| 19 |
+
case 'koboldhorde':
|
| 20 |
+
return { folder: directories.koboldAI_Settings, extension: '.json' };
|
| 21 |
+
case 'novel':
|
| 22 |
+
return { folder: directories.novelAI_Settings, extension: '.json' };
|
| 23 |
+
case 'textgenerationwebui':
|
| 24 |
+
return { folder: directories.textGen_Settings, extension: '.json' };
|
| 25 |
+
case 'openai':
|
| 26 |
+
return { folder: directories.openAI_Settings, extension: '.json' };
|
| 27 |
+
case 'instruct':
|
| 28 |
+
return { folder: directories.instruct, extension: '.json' };
|
| 29 |
+
case 'context':
|
| 30 |
+
return { folder: directories.context, extension: '.json' };
|
| 31 |
+
case 'sysprompt':
|
| 32 |
+
return { folder: directories.sysprompt, extension: '.json' };
|
| 33 |
+
case 'reasoning':
|
| 34 |
+
return { folder: directories.reasoning, extension: '.json' };
|
| 35 |
+
default:
|
| 36 |
+
return { folder: null, extension: null };
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
export const router = express.Router();
|
| 41 |
+
|
| 42 |
+
router.post('/save', function (request, response) {
|
| 43 |
+
const name = sanitize(request.body.name);
|
| 44 |
+
if (!request.body.preset || !name) {
|
| 45 |
+
return response.sendStatus(400);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
const settings = getPresetSettingsByAPI(request.body.apiId, request.user.directories);
|
| 49 |
+
const filename = name + settings.extension;
|
| 50 |
+
|
| 51 |
+
if (!settings.folder) {
|
| 52 |
+
return response.sendStatus(400);
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
const fullpath = path.join(settings.folder, filename);
|
| 56 |
+
writeFileAtomicSync(fullpath, JSON.stringify(request.body.preset, null, 4), 'utf-8');
|
| 57 |
+
return response.send({ name });
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
router.post('/delete', function (request, response) {
|
| 61 |
+
const name = sanitize(request.body.name);
|
| 62 |
+
if (!name) {
|
| 63 |
+
return response.sendStatus(400);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
const settings = getPresetSettingsByAPI(request.body.apiId, request.user.directories);
|
| 67 |
+
const filename = name + settings.extension;
|
| 68 |
+
|
| 69 |
+
if (!settings.folder) {
|
| 70 |
+
return response.sendStatus(400);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
const fullpath = path.join(settings.folder, filename);
|
| 74 |
+
|
| 75 |
+
if (fs.existsSync(fullpath)) {
|
| 76 |
+
fs.unlinkSync(fullpath);
|
| 77 |
+
return response.sendStatus(200);
|
| 78 |
+
} else {
|
| 79 |
+
return response.sendStatus(404);
|
| 80 |
+
}
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
router.post('/restore', function (request, response) {
|
| 84 |
+
try {
|
| 85 |
+
const settings = getPresetSettingsByAPI(request.body.apiId, request.user.directories);
|
| 86 |
+
const name = sanitize(request.body.name);
|
| 87 |
+
const defaultPresets = getDefaultPresets(request.user.directories);
|
| 88 |
+
|
| 89 |
+
const defaultPreset = defaultPresets.find(p => p.name === name && p.folder === settings.folder);
|
| 90 |
+
|
| 91 |
+
const result = { isDefault: false, preset: {} };
|
| 92 |
+
|
| 93 |
+
if (defaultPreset) {
|
| 94 |
+
result.isDefault = true;
|
| 95 |
+
result.preset = getDefaultPresetFile(defaultPreset.filename) || {};
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
return response.send(result);
|
| 99 |
+
} catch (error) {
|
| 100 |
+
console.error(error);
|
| 101 |
+
return response.sendStatus(500);
|
| 102 |
+
}
|
| 103 |
+
});
|
src/endpoints/quick-replies.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from 'node:fs';
|
| 2 |
+
import path from 'node:path';
|
| 3 |
+
|
| 4 |
+
import express from 'express';
|
| 5 |
+
import sanitize from 'sanitize-filename';
|
| 6 |
+
import { sync as writeFileAtomicSync } from 'write-file-atomic';
|
| 7 |
+
|
| 8 |
+
export const router = express.Router();
|
| 9 |
+
|
| 10 |
+
router.post('/save', (request, response) => {
|
| 11 |
+
if (!request.body || !request.body.name) {
|
| 12 |
+
return response.sendStatus(400);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const filename = path.join(request.user.directories.quickreplies, sanitize(`${request.body.name}.json`));
|
| 16 |
+
writeFileAtomicSync(filename, JSON.stringify(request.body, null, 4), 'utf8');
|
| 17 |
+
|
| 18 |
+
return response.sendStatus(200);
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
router.post('/delete', (request, response) => {
|
| 22 |
+
if (!request.body || !request.body.name) {
|
| 23 |
+
return response.sendStatus(400);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
const filename = path.join(request.user.directories.quickreplies, sanitize(`${request.body.name}.json`));
|
| 27 |
+
if (fs.existsSync(filename)) {
|
| 28 |
+
fs.unlinkSync(filename);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
return response.sendStatus(200);
|
| 32 |
+
});
|
src/endpoints/search.js
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fetch from 'node-fetch';
|
| 2 |
+
import express from 'express';
|
| 3 |
+
|
| 4 |
+
import { decode } from 'html-entities';
|
| 5 |
+
import { readSecret, SECRET_KEYS } from './secrets.js';
|
| 6 |
+
import { trimV1 } from '../util.js';
|
| 7 |
+
import { setAdditionalHeaders } from '../additional-headers.js';
|
| 8 |
+
|
| 9 |
+
export const router = express.Router();
|
| 10 |
+
|
| 11 |
+
// Cosplay as Chrome
|
| 12 |
+
const visitHeaders = {
|
| 13 |
+
'Accept': 'text/html',
|
| 14 |
+
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
|
| 15 |
+
'Accept-Language': 'en-US,en;q=0.5',
|
| 16 |
+
'Accept-Encoding': 'gzip, deflate, br',
|
| 17 |
+
'Connection': 'keep-alive',
|
| 18 |
+
'Cache-Control': 'no-cache',
|
| 19 |
+
'Pragma': 'no-cache',
|
| 20 |
+
'TE': 'trailers',
|
| 21 |
+
'DNT': '1',
|
| 22 |
+
'Sec-Fetch-Dest': 'document',
|
| 23 |
+
'Sec-Fetch-Mode': 'navigate',
|
| 24 |
+
'Sec-Fetch-Site': 'none',
|
| 25 |
+
'Sec-Fetch-User': '?1',
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* Extract the transcript of a YouTube video
|
| 30 |
+
* @param {string} videoPageBody HTML of the video page
|
| 31 |
+
* @param {string} lang Language code
|
| 32 |
+
* @returns {Promise<string>} Transcript text
|
| 33 |
+
*/
|
| 34 |
+
async function extractTranscript(videoPageBody, lang) {
|
| 35 |
+
const RE_XML_TRANSCRIPT = /<text start="([^"]*)" dur="([^"]*)">([^<]*)<\/text>/g;
|
| 36 |
+
const splittedHTML = videoPageBody.split('"captions":');
|
| 37 |
+
|
| 38 |
+
if (splittedHTML.length <= 1) {
|
| 39 |
+
if (videoPageBody.includes('class="g-recaptcha"')) {
|
| 40 |
+
throw new Error('Too many requests');
|
| 41 |
+
}
|
| 42 |
+
if (!videoPageBody.includes('"playabilityStatus":')) {
|
| 43 |
+
throw new Error('Video is not available');
|
| 44 |
+
}
|
| 45 |
+
throw new Error('Transcript not available');
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
const captions = (() => {
|
| 49 |
+
try {
|
| 50 |
+
return JSON.parse(splittedHTML[1].split(',"videoDetails')[0].replace('\n', ''));
|
| 51 |
+
} catch (e) {
|
| 52 |
+
return undefined;
|
| 53 |
+
}
|
| 54 |
+
})()?.['playerCaptionsTracklistRenderer'];
|
| 55 |
+
|
| 56 |
+
if (!captions) {
|
| 57 |
+
throw new Error('Transcript disabled');
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
if (!('captionTracks' in captions)) {
|
| 61 |
+
throw new Error('Transcript not available');
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
if (lang && !captions.captionTracks.some(track => track.languageCode === lang)) {
|
| 65 |
+
throw new Error('Transcript not available in this language');
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
const transcriptURL = (lang ? captions.captionTracks.find(track => track.languageCode === lang) : captions.captionTracks[0]).baseUrl;
|
| 69 |
+
const transcriptResponse = await fetch(transcriptURL, {
|
| 70 |
+
headers: {
|
| 71 |
+
...(lang && { 'Accept-Language': lang }),
|
| 72 |
+
'User-Agent': visitHeaders['User-Agent'],
|
| 73 |
+
},
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
if (!transcriptResponse.ok) {
|
| 77 |
+
throw new Error('Transcript request failed');
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
const transcriptBody = await transcriptResponse.text();
|
| 81 |
+
const results = [...transcriptBody.matchAll(RE_XML_TRANSCRIPT)];
|
| 82 |
+
const transcript = results.map((result) => ({
|
| 83 |
+
text: result[3],
|
| 84 |
+
duration: parseFloat(result[2]),
|
| 85 |
+
offset: parseFloat(result[1]),
|
| 86 |
+
lang: lang ?? captions.captionTracks[0].languageCode,
|
| 87 |
+
}));
|
| 88 |
+
// The text is double-encoded
|
| 89 |
+
const transcriptText = transcript.map((line) => decode(decode(line.text))).join(' ');
|
| 90 |
+
return transcriptText;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
router.post('/serpapi', async (request, response) => {
|
| 94 |
+
try {
|
| 95 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.SERPAPI);
|
| 96 |
+
|
| 97 |
+
if (!key) {
|
| 98 |
+
console.error('No SerpApi key found');
|
| 99 |
+
return response.sendStatus(400);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
const { query } = request.body;
|
| 103 |
+
const result = await fetch(`https://serpapi.com/search.json?q=${encodeURIComponent(query)}&api_key=${key}`);
|
| 104 |
+
|
| 105 |
+
console.debug('SerpApi query', query);
|
| 106 |
+
|
| 107 |
+
if (!result.ok) {
|
| 108 |
+
const text = await result.text();
|
| 109 |
+
console.error('SerpApi request failed', result.statusText, text);
|
| 110 |
+
return response.status(500).send(text);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
const data = await result.json();
|
| 114 |
+
console.debug('SerpApi response', data);
|
| 115 |
+
return response.json(data);
|
| 116 |
+
} catch (error) {
|
| 117 |
+
console.error(error);
|
| 118 |
+
return response.sendStatus(500);
|
| 119 |
+
}
|
| 120 |
+
});
|
| 121 |
+
|
| 122 |
+
/**
|
| 123 |
+
* Get the transcript of a YouTube video
|
| 124 |
+
* @copyright https://github.com/Kakulukian/youtube-transcript (MIT License)
|
| 125 |
+
*/
|
| 126 |
+
router.post('/transcript', async (request, response) => {
|
| 127 |
+
try {
|
| 128 |
+
const id = request.body.id;
|
| 129 |
+
const lang = request.body.lang;
|
| 130 |
+
const json = request.body.json;
|
| 131 |
+
|
| 132 |
+
if (!id) {
|
| 133 |
+
console.error('Id is required for /transcript');
|
| 134 |
+
return response.sendStatus(400);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
const videoPageResponse = await fetch(`https://www.youtube.com/watch?v=${id}`, {
|
| 138 |
+
headers: {
|
| 139 |
+
...(lang && { 'Accept-Language': lang }),
|
| 140 |
+
'User-Agent': visitHeaders['User-Agent'],
|
| 141 |
+
},
|
| 142 |
+
});
|
| 143 |
+
|
| 144 |
+
const videoPageBody = await videoPageResponse.text();
|
| 145 |
+
|
| 146 |
+
try {
|
| 147 |
+
const transcriptText = await extractTranscript(videoPageBody, lang);
|
| 148 |
+
return json
|
| 149 |
+
? response.json({ transcript: transcriptText, html: videoPageBody })
|
| 150 |
+
: response.send(transcriptText);
|
| 151 |
+
} catch (error) {
|
| 152 |
+
if (json) {
|
| 153 |
+
return response.json({ html: videoPageBody, transcript: '' });
|
| 154 |
+
}
|
| 155 |
+
throw error;
|
| 156 |
+
}
|
| 157 |
+
} catch (error) {
|
| 158 |
+
console.error(error);
|
| 159 |
+
return response.sendStatus(500);
|
| 160 |
+
}
|
| 161 |
+
});
|
| 162 |
+
|
| 163 |
+
router.post('/searxng', async (request, response) => {
|
| 164 |
+
try {
|
| 165 |
+
const { baseUrl, query, preferences, categories } = request.body;
|
| 166 |
+
|
| 167 |
+
if (!baseUrl || !query) {
|
| 168 |
+
console.error('Missing required parameters for /searxng');
|
| 169 |
+
return response.sendStatus(400);
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
console.debug('SearXNG query', baseUrl, query);
|
| 173 |
+
|
| 174 |
+
const mainPageUrl = new URL(baseUrl);
|
| 175 |
+
const mainPageRequest = await fetch(mainPageUrl, { headers: visitHeaders });
|
| 176 |
+
|
| 177 |
+
if (!mainPageRequest.ok) {
|
| 178 |
+
console.error('SearXNG request failed', mainPageRequest.statusText);
|
| 179 |
+
return response.sendStatus(500);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
const mainPageText = await mainPageRequest.text();
|
| 183 |
+
const clientHref = mainPageText.match(/href="(\/client.+\.css)"/)?.[1];
|
| 184 |
+
|
| 185 |
+
if (clientHref) {
|
| 186 |
+
const clientUrl = new URL(clientHref, baseUrl);
|
| 187 |
+
await fetch(clientUrl, { headers: visitHeaders });
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
const searchUrl = new URL('/search', baseUrl);
|
| 191 |
+
const searchParams = new URLSearchParams();
|
| 192 |
+
searchParams.append('q', query);
|
| 193 |
+
if (preferences) {
|
| 194 |
+
searchParams.append('preferences', preferences);
|
| 195 |
+
}
|
| 196 |
+
if (categories) {
|
| 197 |
+
searchParams.append('categories', categories);
|
| 198 |
+
}
|
| 199 |
+
searchUrl.search = searchParams.toString();
|
| 200 |
+
|
| 201 |
+
const searchResult = await fetch(searchUrl, { headers: visitHeaders });
|
| 202 |
+
|
| 203 |
+
if (!searchResult.ok) {
|
| 204 |
+
const text = await searchResult.text();
|
| 205 |
+
console.error('SearXNG request failed', searchResult.statusText, text);
|
| 206 |
+
return response.sendStatus(500);
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
const data = await searchResult.text();
|
| 210 |
+
return response.send(data);
|
| 211 |
+
} catch (error) {
|
| 212 |
+
console.error('SearXNG request failed', error);
|
| 213 |
+
return response.sendStatus(500);
|
| 214 |
+
}
|
| 215 |
+
});
|
| 216 |
+
|
| 217 |
+
router.post('/tavily', async (request, response) => {
|
| 218 |
+
try {
|
| 219 |
+
const apiKey = readSecret(request.user.directories, SECRET_KEYS.TAVILY);
|
| 220 |
+
|
| 221 |
+
if (!apiKey) {
|
| 222 |
+
console.error('No Tavily key found');
|
| 223 |
+
return response.sendStatus(400);
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
const { query, include_images } = request.body;
|
| 227 |
+
|
| 228 |
+
const body = {
|
| 229 |
+
query: query,
|
| 230 |
+
api_key: apiKey,
|
| 231 |
+
search_depth: 'basic',
|
| 232 |
+
topic: 'general',
|
| 233 |
+
include_answer: true,
|
| 234 |
+
include_raw_content: false,
|
| 235 |
+
include_images: !!include_images,
|
| 236 |
+
include_image_descriptions: false,
|
| 237 |
+
include_domains: [],
|
| 238 |
+
max_results: 10,
|
| 239 |
+
};
|
| 240 |
+
|
| 241 |
+
const result = await fetch('https://api.tavily.com/search', {
|
| 242 |
+
method: 'POST',
|
| 243 |
+
headers: {
|
| 244 |
+
'Content-Type': 'application/json',
|
| 245 |
+
},
|
| 246 |
+
body: JSON.stringify(body),
|
| 247 |
+
});
|
| 248 |
+
|
| 249 |
+
console.debug('Tavily query', query);
|
| 250 |
+
|
| 251 |
+
if (!result.ok) {
|
| 252 |
+
const text = await result.text();
|
| 253 |
+
console.error('Tavily request failed', result.statusText, text);
|
| 254 |
+
return response.status(500).send(text);
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
const data = await result.json();
|
| 258 |
+
console.debug('Tavily response', data);
|
| 259 |
+
return response.json(data);
|
| 260 |
+
} catch (error) {
|
| 261 |
+
console.error(error);
|
| 262 |
+
return response.sendStatus(500);
|
| 263 |
+
}
|
| 264 |
+
});
|
| 265 |
+
|
| 266 |
+
router.post('/koboldcpp', async (request, response) => {
|
| 267 |
+
try {
|
| 268 |
+
const { query, url } = request.body;
|
| 269 |
+
|
| 270 |
+
if (!url) {
|
| 271 |
+
console.error('No URL provided for KoboldCpp search');
|
| 272 |
+
return response.sendStatus(400);
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
console.debug('KoboldCpp search query', query);
|
| 276 |
+
|
| 277 |
+
const baseUrl = trimV1(url);
|
| 278 |
+
const args = {
|
| 279 |
+
method: 'POST',
|
| 280 |
+
headers: {},
|
| 281 |
+
body: JSON.stringify({ q: query }),
|
| 282 |
+
};
|
| 283 |
+
|
| 284 |
+
setAdditionalHeaders(request, args, baseUrl);
|
| 285 |
+
const result = await fetch(`${baseUrl}/api/extra/websearch`, args);
|
| 286 |
+
|
| 287 |
+
if (!result.ok) {
|
| 288 |
+
const text = await result.text();
|
| 289 |
+
console.error('KoboldCpp request failed', result.statusText, text);
|
| 290 |
+
return response.status(500).send(text);
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
const data = await result.json();
|
| 294 |
+
console.debug('KoboldCpp search response', data);
|
| 295 |
+
return response.json(data);
|
| 296 |
+
} catch (error) {
|
| 297 |
+
console.error(error);
|
| 298 |
+
return response.sendStatus(500);
|
| 299 |
+
}
|
| 300 |
+
});
|
| 301 |
+
|
| 302 |
+
router.post('/serper', async (request, response) => {
|
| 303 |
+
try {
|
| 304 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.SERPER);
|
| 305 |
+
|
| 306 |
+
if (!key) {
|
| 307 |
+
console.error('No Serper key found');
|
| 308 |
+
return response.sendStatus(400);
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
const { query, images } = request.body;
|
| 312 |
+
|
| 313 |
+
const url = images
|
| 314 |
+
? 'https://google.serper.dev/images'
|
| 315 |
+
: 'https://google.serper.dev/search';
|
| 316 |
+
|
| 317 |
+
const result = await fetch(url, {
|
| 318 |
+
method: 'POST',
|
| 319 |
+
headers: {
|
| 320 |
+
'X-API-KEY': key,
|
| 321 |
+
'Content-Type': 'application/json',
|
| 322 |
+
},
|
| 323 |
+
redirect: 'follow',
|
| 324 |
+
body: JSON.stringify({ q: query }),
|
| 325 |
+
});
|
| 326 |
+
|
| 327 |
+
console.debug('Serper query', query);
|
| 328 |
+
|
| 329 |
+
if (!result.ok) {
|
| 330 |
+
const text = await result.text();
|
| 331 |
+
console.warn('Serper request failed', result.statusText, text);
|
| 332 |
+
return response.status(500).send(text);
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
const data = await result.json();
|
| 336 |
+
console.debug('Serper response', data);
|
| 337 |
+
return response.json(data);
|
| 338 |
+
} catch (error) {
|
| 339 |
+
console.error(error);
|
| 340 |
+
return response.sendStatus(500);
|
| 341 |
+
}
|
| 342 |
+
});
|
| 343 |
+
|
| 344 |
+
router.post('/zai', async (request, response) => {
|
| 345 |
+
try {
|
| 346 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.ZAI);
|
| 347 |
+
|
| 348 |
+
if (!key) {
|
| 349 |
+
console.error('No Z.AI key found');
|
| 350 |
+
return response.sendStatus(400);
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
const { query } = request.body;
|
| 354 |
+
|
| 355 |
+
if (!query) {
|
| 356 |
+
console.error('No query provided for /zai');
|
| 357 |
+
return response.sendStatus(400);
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
console.debug('Z.AI web search query', query);
|
| 361 |
+
|
| 362 |
+
const result = await fetch('https://api.z.ai/api/paas/v4/web_search', {
|
| 363 |
+
method: 'POST',
|
| 364 |
+
headers: {
|
| 365 |
+
'Content-Type': 'application/json',
|
| 366 |
+
'Authorization': `Bearer ${key}`,
|
| 367 |
+
},
|
| 368 |
+
body: JSON.stringify({
|
| 369 |
+
// TODO: There's only one engine option for now
|
| 370 |
+
search_engine: 'search-prime',
|
| 371 |
+
search_query: query,
|
| 372 |
+
}),
|
| 373 |
+
});
|
| 374 |
+
|
| 375 |
+
if (!result.ok) {
|
| 376 |
+
const text = await result.text();
|
| 377 |
+
console.error('Z.AI request failed', result.statusText, text);
|
| 378 |
+
return response.status(500).send(text);
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
const data = await result.json();
|
| 382 |
+
console.debug('Z.AI web search response', data);
|
| 383 |
+
return response.json(data);
|
| 384 |
+
} catch (error) {
|
| 385 |
+
console.error(error);
|
| 386 |
+
return response.sendStatus(500);
|
| 387 |
+
}
|
| 388 |
+
});
|
| 389 |
+
|
| 390 |
+
router.post('/visit', async (request, response) => {
|
| 391 |
+
try {
|
| 392 |
+
const url = request.body.url;
|
| 393 |
+
const html = Boolean(request.body.html ?? true);
|
| 394 |
+
|
| 395 |
+
if (!url) {
|
| 396 |
+
console.error('No url provided for /visit');
|
| 397 |
+
return response.sendStatus(400);
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
try {
|
| 401 |
+
const urlObj = new URL(url);
|
| 402 |
+
|
| 403 |
+
// Reject relative URLs
|
| 404 |
+
if (urlObj.protocol === null || urlObj.host === null) {
|
| 405 |
+
throw new Error('Invalid URL format');
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
// Reject non-HTTP URLs
|
| 409 |
+
if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') {
|
| 410 |
+
throw new Error('Invalid protocol');
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
// Reject URLs with a non-standard port
|
| 414 |
+
if (urlObj.port !== '') {
|
| 415 |
+
throw new Error('Invalid port');
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
// Reject IP addresses
|
| 419 |
+
if (urlObj.hostname.match(/^\d+\.\d+\.\d+\.\d+$/)) {
|
| 420 |
+
throw new Error('Invalid hostname');
|
| 421 |
+
}
|
| 422 |
+
} catch (error) {
|
| 423 |
+
console.error('Invalid url provided for /visit', url);
|
| 424 |
+
return response.sendStatus(400);
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
console.info('Visiting web URL', url);
|
| 428 |
+
|
| 429 |
+
const result = await fetch(url, { headers: visitHeaders });
|
| 430 |
+
|
| 431 |
+
if (!result.ok) {
|
| 432 |
+
console.error(`Visit failed ${result.status} ${result.statusText}`);
|
| 433 |
+
return response.sendStatus(500);
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
const contentType = String(result.headers.get('content-type'));
|
| 437 |
+
|
| 438 |
+
if (html) {
|
| 439 |
+
if (!contentType.includes('text/html')) {
|
| 440 |
+
console.error(`Visit failed, content-type is ${contentType}, expected text/html`);
|
| 441 |
+
return response.sendStatus(500);
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
const text = await result.text();
|
| 445 |
+
return response.send(text);
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
response.setHeader('Content-Type', contentType);
|
| 449 |
+
const buffer = await result.arrayBuffer();
|
| 450 |
+
return response.send(Buffer.from(buffer));
|
| 451 |
+
} catch (error) {
|
| 452 |
+
console.error(error);
|
| 453 |
+
return response.sendStatus(500);
|
| 454 |
+
}
|
| 455 |
+
});
|
src/endpoints/secrets.js
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from 'node:fs';
|
| 2 |
+
import path from 'node:path';
|
| 3 |
+
|
| 4 |
+
import express from 'express';
|
| 5 |
+
import { sync as writeFileAtomicSync } from 'write-file-atomic';
|
| 6 |
+
import { color, getConfigValue, uuidv4 } from '../util.js';
|
| 7 |
+
|
| 8 |
+
export const SECRETS_FILE = 'secrets.json';
|
| 9 |
+
export const SECRET_KEYS = {
|
| 10 |
+
_MIGRATED: '_migrated',
|
| 11 |
+
HORDE: 'api_key_horde',
|
| 12 |
+
MANCER: 'api_key_mancer',
|
| 13 |
+
VLLM: 'api_key_vllm',
|
| 14 |
+
APHRODITE: 'api_key_aphrodite',
|
| 15 |
+
TABBY: 'api_key_tabby',
|
| 16 |
+
OPENAI: 'api_key_openai',
|
| 17 |
+
NOVEL: 'api_key_novel',
|
| 18 |
+
CLAUDE: 'api_key_claude',
|
| 19 |
+
DEEPL: 'deepl',
|
| 20 |
+
LIBRE: 'libre',
|
| 21 |
+
LIBRE_URL: 'libre_url',
|
| 22 |
+
LINGVA_URL: 'lingva_url',
|
| 23 |
+
OPENROUTER: 'api_key_openrouter',
|
| 24 |
+
AI21: 'api_key_ai21',
|
| 25 |
+
ONERING_URL: 'oneringtranslator_url',
|
| 26 |
+
DEEPLX_URL: 'deeplx_url',
|
| 27 |
+
MAKERSUITE: 'api_key_makersuite',
|
| 28 |
+
VERTEXAI: 'api_key_vertexai',
|
| 29 |
+
SERPAPI: 'api_key_serpapi',
|
| 30 |
+
TOGETHERAI: 'api_key_togetherai',
|
| 31 |
+
MISTRALAI: 'api_key_mistralai',
|
| 32 |
+
CUSTOM: 'api_key_custom',
|
| 33 |
+
OOBA: 'api_key_ooba',
|
| 34 |
+
INFERMATICAI: 'api_key_infermaticai',
|
| 35 |
+
DREAMGEN: 'api_key_dreamgen',
|
| 36 |
+
NOMICAI: 'api_key_nomicai',
|
| 37 |
+
KOBOLDCPP: 'api_key_koboldcpp',
|
| 38 |
+
LLAMACPP: 'api_key_llamacpp',
|
| 39 |
+
COHERE: 'api_key_cohere',
|
| 40 |
+
PERPLEXITY: 'api_key_perplexity',
|
| 41 |
+
GROQ: 'api_key_groq',
|
| 42 |
+
AZURE_TTS: 'api_key_azure_tts',
|
| 43 |
+
FEATHERLESS: 'api_key_featherless',
|
| 44 |
+
HUGGINGFACE: 'api_key_huggingface',
|
| 45 |
+
STABILITY: 'api_key_stability',
|
| 46 |
+
CUSTOM_OPENAI_TTS: 'api_key_custom_openai_tts',
|
| 47 |
+
TAVILY: 'api_key_tavily',
|
| 48 |
+
CHUTES: 'api_key_chutes',
|
| 49 |
+
ELECTRONHUB: 'api_key_electronhub',
|
| 50 |
+
NANOGPT: 'api_key_nanogpt',
|
| 51 |
+
BFL: 'api_key_bfl',
|
| 52 |
+
COMFY_RUNPOD: 'api_key_comfy_runpod',
|
| 53 |
+
FALAI: 'api_key_falai',
|
| 54 |
+
GENERIC: 'api_key_generic',
|
| 55 |
+
DEEPSEEK: 'api_key_deepseek',
|
| 56 |
+
SERPER: 'api_key_serper',
|
| 57 |
+
AIMLAPI: 'api_key_aimlapi',
|
| 58 |
+
XAI: 'api_key_xai',
|
| 59 |
+
FIREWORKS: 'api_key_fireworks',
|
| 60 |
+
VERTEXAI_SERVICE_ACCOUNT: 'vertexai_service_account_json',
|
| 61 |
+
MINIMAX: 'api_key_minimax',
|
| 62 |
+
MINIMAX_GROUP_ID: 'minimax_group_id',
|
| 63 |
+
MOONSHOT: 'api_key_moonshot',
|
| 64 |
+
COMETAPI: 'api_key_cometapi',
|
| 65 |
+
AZURE_OPENAI: 'api_key_azure_openai',
|
| 66 |
+
ZAI: 'api_key_zai',
|
| 67 |
+
SILICONFLOW: 'api_key_siliconflow',
|
| 68 |
+
ELEVENLABS: 'api_key_elevenlabs',
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
/**
|
| 72 |
+
* @typedef {object} SecretValue
|
| 73 |
+
* @property {string} id The unique identifier for the secret
|
| 74 |
+
* @property {string} value The secret value
|
| 75 |
+
* @property {string} label The label for the secret
|
| 76 |
+
* @property {boolean} active Whether the secret is currently active
|
| 77 |
+
*/
|
| 78 |
+
|
| 79 |
+
/**
|
| 80 |
+
* @typedef {object} SecretState
|
| 81 |
+
* @property {string} id The unique identifier for the secret
|
| 82 |
+
* @property {string} value The secret value, masked for security
|
| 83 |
+
* @property {string} label The label for the secret
|
| 84 |
+
* @property {boolean} active Whether the secret is currently active
|
| 85 |
+
*/
|
| 86 |
+
|
| 87 |
+
/**
|
| 88 |
+
* @typedef {Record<string, SecretState[]|null>} SecretStateMap
|
| 89 |
+
*/
|
| 90 |
+
|
| 91 |
+
/**
|
| 92 |
+
* @typedef {{[key: string]: SecretValue[]}} SecretKeys
|
| 93 |
+
* @typedef {{[key: string]: string}} FlatSecretKeys
|
| 94 |
+
*/
|
| 95 |
+
|
| 96 |
+
// These are the keys that are safe to expose, even if allowKeysExposure is false
|
| 97 |
+
const EXPORTABLE_KEYS = [
|
| 98 |
+
SECRET_KEYS.LIBRE_URL,
|
| 99 |
+
SECRET_KEYS.LINGVA_URL,
|
| 100 |
+
SECRET_KEYS.ONERING_URL,
|
| 101 |
+
SECRET_KEYS.DEEPLX_URL,
|
| 102 |
+
];
|
| 103 |
+
|
| 104 |
+
const allowKeysExposure = !!getConfigValue('allowKeysExposure', false, 'boolean');
|
| 105 |
+
|
| 106 |
+
/**
|
| 107 |
+
* SecretManager class to handle all secret operations
|
| 108 |
+
*/
|
| 109 |
+
export class SecretManager {
|
| 110 |
+
/**
|
| 111 |
+
* @param {import('../users.js').UserDirectoryList} directories
|
| 112 |
+
*/
|
| 113 |
+
constructor(directories) {
|
| 114 |
+
this.directories = directories;
|
| 115 |
+
this.filePath = path.join(directories.root, SECRETS_FILE);
|
| 116 |
+
this.defaultSecrets = {};
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/**
|
| 120 |
+
* Ensures the secrets file exists, creating an empty one if necessary
|
| 121 |
+
* @private
|
| 122 |
+
*/
|
| 123 |
+
_ensureSecretsFile() {
|
| 124 |
+
if (!fs.existsSync(this.filePath)) {
|
| 125 |
+
writeFileAtomicSync(this.filePath, JSON.stringify(this.defaultSecrets), 'utf-8');
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
/**
|
| 130 |
+
* Reads and parses the secrets file
|
| 131 |
+
* @private
|
| 132 |
+
* @returns {SecretKeys}
|
| 133 |
+
*/
|
| 134 |
+
_readSecretsFile() {
|
| 135 |
+
this._ensureSecretsFile();
|
| 136 |
+
const fileContents = fs.readFileSync(this.filePath, 'utf-8');
|
| 137 |
+
return /** @type {SecretKeys} */ (JSON.parse(fileContents));
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
/**
|
| 141 |
+
* Writes secrets to the file atomically
|
| 142 |
+
* @private
|
| 143 |
+
* @param {SecretKeys} secrets
|
| 144 |
+
*/
|
| 145 |
+
_writeSecretsFile(secrets) {
|
| 146 |
+
writeFileAtomicSync(this.filePath, JSON.stringify(secrets, null, 4), 'utf-8');
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
/**
|
| 150 |
+
* Deactivates all secrets for a given key
|
| 151 |
+
* @private
|
| 152 |
+
* @param {SecretValue[]} secretArray
|
| 153 |
+
*/
|
| 154 |
+
_deactivateAllSecrets(secretArray) {
|
| 155 |
+
secretArray.forEach(secret => {
|
| 156 |
+
secret.active = false;
|
| 157 |
+
});
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
/**
|
| 161 |
+
* Validates that the secret key exists and has valid structure
|
| 162 |
+
* @private
|
| 163 |
+
* @param {SecretKeys} secrets
|
| 164 |
+
* @param {string} key
|
| 165 |
+
* @returns {boolean}
|
| 166 |
+
*/
|
| 167 |
+
_validateSecretKey(secrets, key) {
|
| 168 |
+
return Object.hasOwn(secrets, key) && Array.isArray(secrets[key]);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
/**
|
| 172 |
+
* Masks a secret value with asterisks in the middle
|
| 173 |
+
* @param {string} value The secret value to mask
|
| 174 |
+
* @param {string} key The secret key
|
| 175 |
+
* @returns {string} A masked version of the value for peeking
|
| 176 |
+
*/
|
| 177 |
+
getMaskedValue(value, key) {
|
| 178 |
+
// No masking if exposure is allowed
|
| 179 |
+
if (allowKeysExposure || EXPORTABLE_KEYS.includes(key)) {
|
| 180 |
+
return value;
|
| 181 |
+
}
|
| 182 |
+
const threshold = 10;
|
| 183 |
+
const exposedChars = 3;
|
| 184 |
+
const placeholder = '*';
|
| 185 |
+
if (value.length <= threshold) {
|
| 186 |
+
return placeholder.repeat(threshold);
|
| 187 |
+
}
|
| 188 |
+
const visibleEnd = value.slice(-exposedChars);
|
| 189 |
+
const maskedMiddle = placeholder.repeat(threshold - exposedChars);
|
| 190 |
+
return `${maskedMiddle}${visibleEnd}`;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
/**
|
| 194 |
+
* Writes a secret to the secrets file
|
| 195 |
+
* @param {string} key Secret key
|
| 196 |
+
* @param {string} value Secret value
|
| 197 |
+
* @param {string} label Label for the secret
|
| 198 |
+
* @returns {string} The ID of the newly created secret
|
| 199 |
+
*/
|
| 200 |
+
writeSecret(key, value, label = 'Unlabeled') {
|
| 201 |
+
const secrets = this._readSecretsFile();
|
| 202 |
+
|
| 203 |
+
if (!Array.isArray(secrets[key])) {
|
| 204 |
+
secrets[key] = [];
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
this._deactivateAllSecrets(secrets[key]);
|
| 208 |
+
|
| 209 |
+
const secret = {
|
| 210 |
+
id: uuidv4(),
|
| 211 |
+
value: value,
|
| 212 |
+
label: label,
|
| 213 |
+
active: true,
|
| 214 |
+
};
|
| 215 |
+
secrets[key].push(secret);
|
| 216 |
+
|
| 217 |
+
this._writeSecretsFile(secrets);
|
| 218 |
+
return secret.id;
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
/**
|
| 222 |
+
* Deletes a secret from the secrets file by its ID
|
| 223 |
+
* @param {string} key Secret key
|
| 224 |
+
* @param {string?} id Secret ID to delete
|
| 225 |
+
*/
|
| 226 |
+
deleteSecret(key, id) {
|
| 227 |
+
if (!fs.existsSync(this.filePath)) {
|
| 228 |
+
return;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
const secrets = this._readSecretsFile();
|
| 232 |
+
|
| 233 |
+
if (!this._validateSecretKey(secrets, key)) {
|
| 234 |
+
return;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
const secretArray = secrets[key];
|
| 238 |
+
const targetIndex = secretArray.findIndex(s => id ? s.id === id : s.active);
|
| 239 |
+
|
| 240 |
+
// Delete the secret if found
|
| 241 |
+
if (targetIndex !== -1) {
|
| 242 |
+
secretArray.splice(targetIndex, 1);
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
// Reactivate the first secret if none are active
|
| 246 |
+
if (secretArray.length && !secretArray.some(s => s.active)) {
|
| 247 |
+
secretArray[0].active = true;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
// Remove the key if no secrets left
|
| 251 |
+
if (secretArray.length === 0) {
|
| 252 |
+
delete secrets[key];
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
this._writeSecretsFile(secrets);
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
/**
|
| 259 |
+
* Reads the active secret value for a given key
|
| 260 |
+
* @param {string} key Secret key
|
| 261 |
+
* @param {string?} id ID of the secret to read (optional)
|
| 262 |
+
* @returns {string} Secret value or empty string if not found
|
| 263 |
+
*/
|
| 264 |
+
readSecret(key, id) {
|
| 265 |
+
if (!fs.existsSync(this.filePath)) {
|
| 266 |
+
return '';
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
const secrets = this._readSecretsFile();
|
| 270 |
+
const secretArray = secrets[key];
|
| 271 |
+
|
| 272 |
+
if (Array.isArray(secretArray) && secretArray.length > 0) {
|
| 273 |
+
const activeSecret = secretArray.find(s => id ? s.id === id : s.active);
|
| 274 |
+
return activeSecret?.value || '';
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
return '';
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
/**
|
| 281 |
+
* Activates a specific secret by ID for a given key
|
| 282 |
+
* @param {string} key Secret key to rotate
|
| 283 |
+
* @param {string} id ID of the secret to activate
|
| 284 |
+
*/
|
| 285 |
+
rotateSecret(key, id) {
|
| 286 |
+
if (!fs.existsSync(this.filePath)) {
|
| 287 |
+
return;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
const secrets = this._readSecretsFile();
|
| 291 |
+
|
| 292 |
+
if (!this._validateSecretKey(secrets, key)) {
|
| 293 |
+
return;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
const secretArray = secrets[key];
|
| 297 |
+
const targetIndex = secretArray.findIndex(s => s.id === id);
|
| 298 |
+
|
| 299 |
+
if (targetIndex === -1) {
|
| 300 |
+
console.warn(`Secret with ID ${id} not found for key ${key}`);
|
| 301 |
+
return;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
this._deactivateAllSecrets(secretArray);
|
| 305 |
+
secretArray[targetIndex].active = true;
|
| 306 |
+
|
| 307 |
+
this._writeSecretsFile(secrets);
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
/**
|
| 311 |
+
* Renames a secret by its ID
|
| 312 |
+
* @param {string} key Secret key to rename
|
| 313 |
+
* @param {string} id ID of the secret to rename
|
| 314 |
+
* @param {string} label New label for the secret
|
| 315 |
+
*/
|
| 316 |
+
renameSecret(key, id, label) {
|
| 317 |
+
const secrets = this._readSecretsFile();
|
| 318 |
+
|
| 319 |
+
if (!this._validateSecretKey(secrets, key)) {
|
| 320 |
+
return;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
const secretArray = secrets[key];
|
| 324 |
+
const targetIndex = secretArray.findIndex(s => s.id === id);
|
| 325 |
+
|
| 326 |
+
if (targetIndex === -1) {
|
| 327 |
+
console.warn(`Secret with ID ${id} not found for key ${key}`);
|
| 328 |
+
return;
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
secretArray[targetIndex].label = label;
|
| 332 |
+
this._writeSecretsFile(secrets);
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
/**
|
| 336 |
+
* Gets the state of all secrets (whether they exist or not)
|
| 337 |
+
* @returns {SecretStateMap} Secret state
|
| 338 |
+
*/
|
| 339 |
+
getSecretState() {
|
| 340 |
+
const secrets = this._readSecretsFile();
|
| 341 |
+
/** @type {SecretStateMap} */
|
| 342 |
+
const state = {};
|
| 343 |
+
|
| 344 |
+
for (const key of Object.values(SECRET_KEYS)) {
|
| 345 |
+
// Skip migration marker
|
| 346 |
+
if (key === SECRET_KEYS._MIGRATED) {
|
| 347 |
+
continue;
|
| 348 |
+
}
|
| 349 |
+
const value = secrets[key];
|
| 350 |
+
if (value && Array.isArray(value) && value.length > 0) {
|
| 351 |
+
state[key] = value.map(secret => ({
|
| 352 |
+
id: secret.id,
|
| 353 |
+
value: this.getMaskedValue(secret.value, key),
|
| 354 |
+
label: secret.label,
|
| 355 |
+
active: secret.active,
|
| 356 |
+
}));
|
| 357 |
+
} else {
|
| 358 |
+
// No secrets for this key
|
| 359 |
+
state[key] = null;
|
| 360 |
+
}
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
return state;
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
/**
|
| 367 |
+
* Gets all secrets (for admin viewing)
|
| 368 |
+
* @returns {SecretKeys} All secrets
|
| 369 |
+
*/
|
| 370 |
+
getAllSecrets() {
|
| 371 |
+
return this._readSecretsFile();
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
/**
|
| 375 |
+
* Migrates legacy flat secrets format to new format
|
| 376 |
+
*/
|
| 377 |
+
migrateFlatSecrets() {
|
| 378 |
+
if (!fs.existsSync(this.filePath)) {
|
| 379 |
+
return;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
const fileContents = fs.readFileSync(this.filePath, 'utf8');
|
| 383 |
+
const secrets = /** @type {FlatSecretKeys} */ (JSON.parse(fileContents));
|
| 384 |
+
const values = Object.values(secrets);
|
| 385 |
+
|
| 386 |
+
// Check if already migrated
|
| 387 |
+
if (secrets[SECRET_KEYS._MIGRATED] || values.length === 0 || values.some(v => Array.isArray(v))) {
|
| 388 |
+
return;
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
/** @type {SecretKeys} */
|
| 392 |
+
const migratedSecrets = {};
|
| 393 |
+
|
| 394 |
+
for (const [key, value] of Object.entries(secrets)) {
|
| 395 |
+
if (typeof value === 'string' && value.trim()) {
|
| 396 |
+
migratedSecrets[key] = [{
|
| 397 |
+
id: uuidv4(),
|
| 398 |
+
value: value,
|
| 399 |
+
label: key,
|
| 400 |
+
active: true,
|
| 401 |
+
}];
|
| 402 |
+
}
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
// Mark as migrated
|
| 406 |
+
migratedSecrets[SECRET_KEYS._MIGRATED] = [];
|
| 407 |
+
|
| 408 |
+
// Save backup of the old secrets file
|
| 409 |
+
const backupFilePath = path.join(this.directories.backups, `secrets_migration_${Date.now()}.json`);
|
| 410 |
+
fs.cpSync(this.filePath, backupFilePath);
|
| 411 |
+
|
| 412 |
+
this._writeSecretsFile(migratedSecrets);
|
| 413 |
+
console.info(color.green('Secrets migrated successfully, old secrets backed up to:'), backupFilePath);
|
| 414 |
+
}
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
//#region Backwards compatibility
|
| 418 |
+
/**
|
| 419 |
+
* Writes a secret to the secrets file
|
| 420 |
+
* @param {import('../users.js').UserDirectoryList} directories User directories
|
| 421 |
+
* @param {string} key Secret key
|
| 422 |
+
* @param {string} value Secret value
|
| 423 |
+
*/
|
| 424 |
+
export function writeSecret(directories, key, value) {
|
| 425 |
+
return new SecretManager(directories).writeSecret(key, value);
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
/**
|
| 429 |
+
* Deletes a secret from the secrets file
|
| 430 |
+
* @param {import('../users.js').UserDirectoryList} directories User directories
|
| 431 |
+
* @param {string} key Secret key
|
| 432 |
+
*/
|
| 433 |
+
export function deleteSecret(directories, key) {
|
| 434 |
+
return new SecretManager(directories).deleteSecret(key, null);
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
/**
|
| 438 |
+
* Reads a secret from the secrets file
|
| 439 |
+
* @param {import('../users.js').UserDirectoryList} directories User directories
|
| 440 |
+
* @param {string} key Secret key
|
| 441 |
+
* @returns {string} Secret value
|
| 442 |
+
*/
|
| 443 |
+
export function readSecret(directories, key) {
|
| 444 |
+
return new SecretManager(directories).readSecret(key, null);
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
/**
|
| 448 |
+
* Reads the secret state from the secrets file
|
| 449 |
+
* @param {import('../users.js').UserDirectoryList} directories User directories
|
| 450 |
+
* @returns {Record<string, boolean>} Secret state
|
| 451 |
+
*/
|
| 452 |
+
export function readSecretState(directories) {
|
| 453 |
+
const state = new SecretManager(directories).getSecretState();
|
| 454 |
+
const result = /** @type {Record<string, boolean>} */ ({});
|
| 455 |
+
for (const key of Object.values(SECRET_KEYS)) {
|
| 456 |
+
// Skip migration marker
|
| 457 |
+
if (key === SECRET_KEYS._MIGRATED) {
|
| 458 |
+
continue;
|
| 459 |
+
}
|
| 460 |
+
result[key] = Array.isArray(state[key]) && state[key].length > 0;
|
| 461 |
+
}
|
| 462 |
+
return result;
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
/**
|
| 466 |
+
* Reads all secrets from the secrets file
|
| 467 |
+
* @param {import('../users.js').UserDirectoryList} directories User directories
|
| 468 |
+
* @returns {Record<string, string>} Secrets
|
| 469 |
+
*/
|
| 470 |
+
export function getAllSecrets(directories) {
|
| 471 |
+
const secrets = new SecretManager(directories).getAllSecrets();
|
| 472 |
+
const result = /** @type {Record<string, string>} */ ({});
|
| 473 |
+
for (const [key, values] of Object.entries(secrets)) {
|
| 474 |
+
// Skip migration marker
|
| 475 |
+
if (key === SECRET_KEYS._MIGRATED) {
|
| 476 |
+
continue;
|
| 477 |
+
}
|
| 478 |
+
if (Array.isArray(values) && values.length > 0) {
|
| 479 |
+
const activeSecret = values.find(secret => secret.active);
|
| 480 |
+
if (activeSecret) {
|
| 481 |
+
result[key] = activeSecret.value;
|
| 482 |
+
}
|
| 483 |
+
}
|
| 484 |
+
}
|
| 485 |
+
return result;
|
| 486 |
+
}
|
| 487 |
+
//#endregion
|
| 488 |
+
|
| 489 |
+
/**
|
| 490 |
+
* Migrates legacy flat secrets format to the new format for all user directories
|
| 491 |
+
* @param {import('../users.js').UserDirectoryList[]} directoriesList User directories
|
| 492 |
+
*/
|
| 493 |
+
export function migrateFlatSecrets(directoriesList) {
|
| 494 |
+
for (const directories of directoriesList) {
|
| 495 |
+
try {
|
| 496 |
+
const manager = new SecretManager(directories);
|
| 497 |
+
manager.migrateFlatSecrets();
|
| 498 |
+
} catch (error) {
|
| 499 |
+
console.warn(color.red(`Failed to migrate secrets for ${directories.root}:`), error);
|
| 500 |
+
}
|
| 501 |
+
}
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
export const router = express.Router();
|
| 505 |
+
|
| 506 |
+
router.post('/write', (request, response) => {
|
| 507 |
+
try {
|
| 508 |
+
const { key, value, label } = request.body;
|
| 509 |
+
|
| 510 |
+
if (!key || typeof value !== 'string') {
|
| 511 |
+
return response.status(400).send('Invalid key or value');
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
const manager = new SecretManager(request.user.directories);
|
| 515 |
+
const id = manager.writeSecret(key, value, label);
|
| 516 |
+
|
| 517 |
+
return response.send({ id });
|
| 518 |
+
} catch (error) {
|
| 519 |
+
console.error('Error writing secret:', error);
|
| 520 |
+
return response.sendStatus(500);
|
| 521 |
+
}
|
| 522 |
+
});
|
| 523 |
+
|
| 524 |
+
router.post('/read', (request, response) => {
|
| 525 |
+
try {
|
| 526 |
+
const manager = new SecretManager(request.user.directories);
|
| 527 |
+
const state = manager.getSecretState();
|
| 528 |
+
return response.send(state);
|
| 529 |
+
} catch (error) {
|
| 530 |
+
console.error('Error reading secret state:', error);
|
| 531 |
+
return response.send({});
|
| 532 |
+
}
|
| 533 |
+
});
|
| 534 |
+
|
| 535 |
+
router.post('/view', (request, response) => {
|
| 536 |
+
try {
|
| 537 |
+
if (!allowKeysExposure) {
|
| 538 |
+
console.error('secrets.json could not be viewed unless allowKeysExposure in config.yaml is set to true');
|
| 539 |
+
return response.sendStatus(403);
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
const secrets = getAllSecrets(request.user.directories);
|
| 543 |
+
|
| 544 |
+
if (!secrets) {
|
| 545 |
+
return response.sendStatus(404);
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
return response.send(secrets);
|
| 549 |
+
} catch (error) {
|
| 550 |
+
console.error('Error viewing secrets:', error);
|
| 551 |
+
return response.sendStatus(500);
|
| 552 |
+
}
|
| 553 |
+
});
|
| 554 |
+
|
| 555 |
+
router.post('/find', (request, response) => {
|
| 556 |
+
try {
|
| 557 |
+
const { key, id } = request.body;
|
| 558 |
+
|
| 559 |
+
if (!key) {
|
| 560 |
+
return response.status(400).send('Key is required');
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
if (!allowKeysExposure && !EXPORTABLE_KEYS.includes(key)) {
|
| 564 |
+
console.error('Cannot fetch secrets unless allowKeysExposure in config.yaml is set to true');
|
| 565 |
+
return response.sendStatus(403);
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
const manager = new SecretManager(request.user.directories);
|
| 569 |
+
const state = manager.getSecretState();
|
| 570 |
+
|
| 571 |
+
if (!state[key]) {
|
| 572 |
+
return response.sendStatus(404);
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
const secretValue = manager.readSecret(key, id);
|
| 576 |
+
return response.send({ value: secretValue });
|
| 577 |
+
} catch (error) {
|
| 578 |
+
console.error('Error finding secret:', error);
|
| 579 |
+
return response.sendStatus(500);
|
| 580 |
+
}
|
| 581 |
+
});
|
| 582 |
+
|
| 583 |
+
router.post('/delete', (request, response) => {
|
| 584 |
+
try {
|
| 585 |
+
const { key, id } = request.body;
|
| 586 |
+
|
| 587 |
+
if (!key) {
|
| 588 |
+
return response.status(400).send('Key and ID are required');
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
const manager = new SecretManager(request.user.directories);
|
| 592 |
+
manager.deleteSecret(key, id);
|
| 593 |
+
|
| 594 |
+
return response.sendStatus(204);
|
| 595 |
+
} catch (error) {
|
| 596 |
+
console.error('Error deleting secret:', error);
|
| 597 |
+
return response.sendStatus(500);
|
| 598 |
+
}
|
| 599 |
+
});
|
| 600 |
+
|
| 601 |
+
router.post('/rotate', (request, response) => {
|
| 602 |
+
try {
|
| 603 |
+
const { key, id } = request.body;
|
| 604 |
+
|
| 605 |
+
if (!key || !id) {
|
| 606 |
+
return response.status(400).send('Key and ID are required');
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
const manager = new SecretManager(request.user.directories);
|
| 610 |
+
manager.rotateSecret(key, id);
|
| 611 |
+
|
| 612 |
+
return response.sendStatus(204);
|
| 613 |
+
} catch (error) {
|
| 614 |
+
console.error('Error rotating secret:', error);
|
| 615 |
+
return response.sendStatus(500);
|
| 616 |
+
}
|
| 617 |
+
});
|
| 618 |
+
|
| 619 |
+
router.post('/rename', (request, response) => {
|
| 620 |
+
try {
|
| 621 |
+
const { key, id, label } = request.body;
|
| 622 |
+
|
| 623 |
+
if (!key || !id || !label) {
|
| 624 |
+
return response.status(400).send('Key, ID, and label are required');
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
const manager = new SecretManager(request.user.directories);
|
| 628 |
+
manager.renameSecret(key, id, label);
|
| 629 |
+
|
| 630 |
+
return response.sendStatus(204);
|
| 631 |
+
} catch (error) {
|
| 632 |
+
console.error('Error renaming secret:', error);
|
| 633 |
+
return response.sendStatus(500);
|
| 634 |
+
}
|
| 635 |
+
});
|
src/endpoints/secure-generate.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import express from 'express';
|
| 2 |
+
import fetch from 'node-fetch';
|
| 3 |
+
import fs from 'node:fs';
|
| 4 |
+
import path from 'node:path';
|
| 5 |
+
import { forwardFetchResponse } from '../util.js';
|
| 6 |
+
|
| 7 |
+
export const router = express.Router();
|
| 8 |
+
|
| 9 |
+
function getHiddenPrompts() {
|
| 10 |
+
const HIDDEN_PROMPTS_FILE = path.join(globalThis.DATA_ROOT || '', 'hidden_prompts.json');
|
| 11 |
+
try {
|
| 12 |
+
if (fs.existsSync(HIDDEN_PROMPTS_FILE)) {
|
| 13 |
+
return JSON.parse(fs.readFileSync(HIDDEN_PROMPTS_FILE, 'utf8'));
|
| 14 |
+
}
|
| 15 |
+
} catch (err) {
|
| 16 |
+
console.error('Error reading hidden prompts:', err);
|
| 17 |
+
}
|
| 18 |
+
return {};
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
router.post('/', async (req, res) => {
|
| 22 |
+
const { target_url, hidden_prompt_id, ...llm_params } = req.body;
|
| 23 |
+
|
| 24 |
+
if (!target_url) {
|
| 25 |
+
return res.status(400).send('Missing target_url');
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const hiddenPrompts = getHiddenPrompts();
|
| 29 |
+
const hiddenPrompt = hiddenPrompts[hidden_prompt_id];
|
| 30 |
+
|
| 31 |
+
if (hiddenPrompt && llm_params.messages) {
|
| 32 |
+
console.log(`Injecting hidden prompt: ${hidden_prompt_id}`);
|
| 33 |
+
// Inject at the beginning of the messages array
|
| 34 |
+
llm_params.messages.unshift({
|
| 35 |
+
role: hiddenPrompt.role || 'system',
|
| 36 |
+
content: hiddenPrompt.prompt
|
| 37 |
+
});
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
try {
|
| 41 |
+
const headers = { ...req.headers };
|
| 42 |
+
// Remove host and other potentially problematic headers
|
| 43 |
+
delete headers.host;
|
| 44 |
+
delete headers['content-length'];
|
| 45 |
+
delete headers['x-csrf-token'];
|
| 46 |
+
delete headers.cookie;
|
| 47 |
+
|
| 48 |
+
const response = await fetch(target_url, {
|
| 49 |
+
method: 'POST',
|
| 50 |
+
headers: headers,
|
| 51 |
+
body: JSON.stringify(llm_params),
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
forwardFetchResponse(response, res);
|
| 55 |
+
} catch (error) {
|
| 56 |
+
console.error('Error in secure-generate proxy:', error);
|
| 57 |
+
res.status(500).send('Error in secure-generate proxy: ' + error.message);
|
| 58 |
+
}
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
router.get('/list', (req, res) => {
|
| 62 |
+
const hiddenPrompts = getHiddenPrompts();
|
| 63 |
+
const list = Object.entries(hiddenPrompts).map(([id, data]) => ({
|
| 64 |
+
id,
|
| 65 |
+
label: data.name || id
|
| 66 |
+
}));
|
| 67 |
+
res.json(list);
|
| 68 |
+
});
|
src/endpoints/settings.js
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from 'node:fs';
|
| 2 |
+
import path from 'node:path';
|
| 3 |
+
|
| 4 |
+
import express from 'express';
|
| 5 |
+
import _ from 'lodash';
|
| 6 |
+
import { sync as writeFileAtomicSync } from 'write-file-atomic';
|
| 7 |
+
|
| 8 |
+
import { SETTINGS_FILE } from '../constants.js';
|
| 9 |
+
import { getConfigValue, generateTimestamp, removeOldBackups } from '../util.js';
|
| 10 |
+
import { getAllUserHandles, getUserDirectories } from '../users.js';
|
| 11 |
+
import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
|
| 12 |
+
|
| 13 |
+
const ENABLE_EXTENSIONS = !!getConfigValue('extensions.enabled', true, 'boolean');
|
| 14 |
+
const ENABLE_EXTENSIONS_AUTO_UPDATE = !!getConfigValue('extensions.autoUpdate', true, 'boolean');
|
| 15 |
+
const ENABLE_ACCOUNTS = !!getConfigValue('enableUserAccounts', false, 'boolean');
|
| 16 |
+
|
| 17 |
+
// 10 minutes
|
| 18 |
+
const AUTOSAVE_INTERVAL = 10 * 60 * 1000;
|
| 19 |
+
|
| 20 |
+
/**
|
| 21 |
+
* Map of functions to trigger settings autosave for a user.
|
| 22 |
+
* @type {Map<string, function>}
|
| 23 |
+
*/
|
| 24 |
+
const AUTOSAVE_FUNCTIONS = new Map();
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* Triggers autosave for a user every 10 minutes.
|
| 28 |
+
* @param {string} handle User handle
|
| 29 |
+
* @returns {void}
|
| 30 |
+
*/
|
| 31 |
+
function triggerAutoSave(handle) {
|
| 32 |
+
if (!AUTOSAVE_FUNCTIONS.has(handle)) {
|
| 33 |
+
const throttledAutoSave = _.throttle(() => backupUserSettings(handle, true), AUTOSAVE_INTERVAL);
|
| 34 |
+
AUTOSAVE_FUNCTIONS.set(handle, throttledAutoSave);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
const functionToCall = AUTOSAVE_FUNCTIONS.get(handle);
|
| 38 |
+
if (functionToCall && typeof functionToCall === 'function') {
|
| 39 |
+
functionToCall();
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/**
|
| 44 |
+
* Reads and parses files from a directory.
|
| 45 |
+
* @param {string} directoryPath Path to the directory
|
| 46 |
+
* @param {string} fileExtension File extension
|
| 47 |
+
* @returns {Array} Parsed files
|
| 48 |
+
*/
|
| 49 |
+
function readAndParseFromDirectory(directoryPath, fileExtension = '.json') {
|
| 50 |
+
const files = fs
|
| 51 |
+
.readdirSync(directoryPath)
|
| 52 |
+
.filter(x => path.parse(x).ext == fileExtension)
|
| 53 |
+
.sort();
|
| 54 |
+
|
| 55 |
+
const parsedFiles = [];
|
| 56 |
+
|
| 57 |
+
files.forEach(item => {
|
| 58 |
+
try {
|
| 59 |
+
const file = fs.readFileSync(path.join(directoryPath, item), 'utf-8');
|
| 60 |
+
parsedFiles.push(fileExtension == '.json' ? JSON.parse(file) : file);
|
| 61 |
+
}
|
| 62 |
+
catch {
|
| 63 |
+
// skip
|
| 64 |
+
}
|
| 65 |
+
});
|
| 66 |
+
|
| 67 |
+
return parsedFiles;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/**
|
| 71 |
+
* Gets a sort function for sorting strings.
|
| 72 |
+
* @param {*} _
|
| 73 |
+
* @returns {(a: string, b: string) => number} Sort function
|
| 74 |
+
*/
|
| 75 |
+
function sortByName(_) {
|
| 76 |
+
return (a, b) => a.localeCompare(b);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
/**
|
| 80 |
+
* Gets backup file prefix for user settings.
|
| 81 |
+
* @param {string} handle User handle
|
| 82 |
+
* @returns {string} File prefix
|
| 83 |
+
*/
|
| 84 |
+
export function getSettingsBackupFilePrefix(handle) {
|
| 85 |
+
return `settings_${handle}_`;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
function readPresetsFromDirectory(directoryPath, options = {}) {
|
| 89 |
+
const {
|
| 90 |
+
sortFunction,
|
| 91 |
+
removeFileExtension = false,
|
| 92 |
+
fileExtension = '.json',
|
| 93 |
+
} = options;
|
| 94 |
+
|
| 95 |
+
const files = fs.readdirSync(directoryPath).sort(sortFunction).filter(x => path.parse(x).ext == fileExtension);
|
| 96 |
+
const fileContents = [];
|
| 97 |
+
const fileNames = [];
|
| 98 |
+
|
| 99 |
+
files.forEach(item => {
|
| 100 |
+
try {
|
| 101 |
+
const file = fs.readFileSync(path.join(directoryPath, item), 'utf8');
|
| 102 |
+
JSON.parse(file);
|
| 103 |
+
fileContents.push(file);
|
| 104 |
+
fileNames.push(removeFileExtension ? item.replace(/\.[^/.]+$/, '') : item);
|
| 105 |
+
} catch {
|
| 106 |
+
// skip
|
| 107 |
+
console.warn(`${item} is not a valid JSON`);
|
| 108 |
+
}
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
return { fileContents, fileNames };
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
async function backupSettings() {
|
| 115 |
+
try {
|
| 116 |
+
const userHandles = await getAllUserHandles();
|
| 117 |
+
|
| 118 |
+
for (const handle of userHandles) {
|
| 119 |
+
backupUserSettings(handle, true);
|
| 120 |
+
}
|
| 121 |
+
} catch (err) {
|
| 122 |
+
console.error('Could not backup settings file', err);
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
/**
|
| 127 |
+
* Makes a backup of the user's settings file.
|
| 128 |
+
* @param {string} handle User handle
|
| 129 |
+
* @param {boolean} preventDuplicates Prevent duplicate backups
|
| 130 |
+
* @returns {void}
|
| 131 |
+
*/
|
| 132 |
+
function backupUserSettings(handle, preventDuplicates) {
|
| 133 |
+
const userDirectories = getUserDirectories(handle);
|
| 134 |
+
|
| 135 |
+
if (!fs.existsSync(userDirectories.root)) {
|
| 136 |
+
return;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
const backupFile = path.join(userDirectories.backups, `${getSettingsBackupFilePrefix(handle)}${generateTimestamp()}.json`);
|
| 140 |
+
const sourceFile = path.join(userDirectories.root, SETTINGS_FILE);
|
| 141 |
+
|
| 142 |
+
if (preventDuplicates && isDuplicateBackup(handle, sourceFile)) {
|
| 143 |
+
return;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
if (!fs.existsSync(sourceFile)) {
|
| 147 |
+
return;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
fs.copyFileSync(sourceFile, backupFile);
|
| 151 |
+
removeOldBackups(userDirectories.backups, `settings_${handle}`);
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
/**
|
| 155 |
+
* Checks if the backup would be a duplicate.
|
| 156 |
+
* @param {string} handle User handle
|
| 157 |
+
* @param {string} sourceFile Source file path
|
| 158 |
+
* @returns {boolean} True if the backup is a duplicate
|
| 159 |
+
*/
|
| 160 |
+
function isDuplicateBackup(handle, sourceFile) {
|
| 161 |
+
const latestBackup = getLatestBackup(handle);
|
| 162 |
+
if (!latestBackup) {
|
| 163 |
+
return false;
|
| 164 |
+
}
|
| 165 |
+
return areFilesEqual(latestBackup, sourceFile);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
/**
|
| 169 |
+
* Returns true if the two files are equal.
|
| 170 |
+
* @param {string} file1 File path
|
| 171 |
+
* @param {string} file2 File path
|
| 172 |
+
*/
|
| 173 |
+
function areFilesEqual(file1, file2) {
|
| 174 |
+
if (!fs.existsSync(file1) || !fs.existsSync(file2)) {
|
| 175 |
+
return false;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
const content1 = fs.readFileSync(file1);
|
| 179 |
+
const content2 = fs.readFileSync(file2);
|
| 180 |
+
return content1.toString() === content2.toString();
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
/**
|
| 184 |
+
* Gets the latest backup file for a user.
|
| 185 |
+
* @param {string} handle User handle
|
| 186 |
+
* @returns {string|null} Latest backup file. Null if no backup exists.
|
| 187 |
+
*/
|
| 188 |
+
function getLatestBackup(handle) {
|
| 189 |
+
const userDirectories = getUserDirectories(handle);
|
| 190 |
+
const backupFiles = fs.readdirSync(userDirectories.backups)
|
| 191 |
+
.filter(x => x.startsWith(getSettingsBackupFilePrefix(handle)))
|
| 192 |
+
.map(x => ({ name: x, ctime: fs.statSync(path.join(userDirectories.backups, x)).ctimeMs }));
|
| 193 |
+
const latestBackup = backupFiles.sort((a, b) => b.ctime - a.ctime)[0]?.name;
|
| 194 |
+
if (!latestBackup) {
|
| 195 |
+
return null;
|
| 196 |
+
}
|
| 197 |
+
return path.join(userDirectories.backups, latestBackup);
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
export const router = express.Router();
|
| 201 |
+
|
| 202 |
+
router.post('/save', function (request, response) {
|
| 203 |
+
try {
|
| 204 |
+
const pathToSettings = path.join(request.user.directories.root, SETTINGS_FILE);
|
| 205 |
+
writeFileAtomicSync(pathToSettings, JSON.stringify(request.body, null, 4), 'utf8');
|
| 206 |
+
triggerAutoSave(request.user.profile.handle);
|
| 207 |
+
response.send({ result: 'ok' });
|
| 208 |
+
} catch (err) {
|
| 209 |
+
console.error(err);
|
| 210 |
+
response.send(err);
|
| 211 |
+
}
|
| 212 |
+
});
|
| 213 |
+
|
| 214 |
+
// Wintermute's code
|
| 215 |
+
router.post('/get', (request, response) => {
|
| 216 |
+
let settings;
|
| 217 |
+
try {
|
| 218 |
+
const pathToSettings = path.join(request.user.directories.root, SETTINGS_FILE);
|
| 219 |
+
settings = fs.readFileSync(pathToSettings, 'utf8');
|
| 220 |
+
} catch (e) {
|
| 221 |
+
return response.sendStatus(500);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
// NovelAI Settings
|
| 225 |
+
const { fileContents: novelai_settings, fileNames: novelai_setting_names }
|
| 226 |
+
= readPresetsFromDirectory(request.user.directories.novelAI_Settings, {
|
| 227 |
+
sortFunction: sortByName(request.user.directories.novelAI_Settings),
|
| 228 |
+
removeFileExtension: true,
|
| 229 |
+
});
|
| 230 |
+
|
| 231 |
+
// OpenAI Settings
|
| 232 |
+
const { fileContents: openai_settings, fileNames: openai_setting_names }
|
| 233 |
+
= readPresetsFromDirectory(request.user.directories.openAI_Settings, {
|
| 234 |
+
sortFunction: sortByName(request.user.directories.openAI_Settings), removeFileExtension: true,
|
| 235 |
+
});
|
| 236 |
+
|
| 237 |
+
// TextGenerationWebUI Settings
|
| 238 |
+
const { fileContents: textgenerationwebui_presets, fileNames: textgenerationwebui_preset_names }
|
| 239 |
+
= readPresetsFromDirectory(request.user.directories.textGen_Settings, {
|
| 240 |
+
sortFunction: sortByName(request.user.directories.textGen_Settings), removeFileExtension: true,
|
| 241 |
+
});
|
| 242 |
+
|
| 243 |
+
//Kobold
|
| 244 |
+
const { fileContents: koboldai_settings, fileNames: koboldai_setting_names }
|
| 245 |
+
= readPresetsFromDirectory(request.user.directories.koboldAI_Settings, {
|
| 246 |
+
sortFunction: sortByName(request.user.directories.koboldAI_Settings), removeFileExtension: true,
|
| 247 |
+
});
|
| 248 |
+
|
| 249 |
+
const worldFiles = fs
|
| 250 |
+
.readdirSync(request.user.directories.worlds)
|
| 251 |
+
.filter(file => path.extname(file).toLowerCase() === '.json')
|
| 252 |
+
.sort((a, b) => a.localeCompare(b));
|
| 253 |
+
const world_names = worldFiles.map(item => path.parse(item).name);
|
| 254 |
+
|
| 255 |
+
const themes = readAndParseFromDirectory(request.user.directories.themes);
|
| 256 |
+
const movingUIPresets = readAndParseFromDirectory(request.user.directories.movingUI);
|
| 257 |
+
const quickReplyPresets = readAndParseFromDirectory(request.user.directories.quickreplies);
|
| 258 |
+
|
| 259 |
+
const instruct = readAndParseFromDirectory(request.user.directories.instruct);
|
| 260 |
+
const context = readAndParseFromDirectory(request.user.directories.context);
|
| 261 |
+
const sysprompt = readAndParseFromDirectory(request.user.directories.sysprompt);
|
| 262 |
+
const reasoning = readAndParseFromDirectory(request.user.directories.reasoning);
|
| 263 |
+
|
| 264 |
+
response.send({
|
| 265 |
+
settings,
|
| 266 |
+
koboldai_settings,
|
| 267 |
+
koboldai_setting_names,
|
| 268 |
+
world_names,
|
| 269 |
+
novelai_settings,
|
| 270 |
+
novelai_setting_names,
|
| 271 |
+
openai_settings,
|
| 272 |
+
openai_setting_names,
|
| 273 |
+
textgenerationwebui_presets,
|
| 274 |
+
textgenerationwebui_preset_names,
|
| 275 |
+
themes,
|
| 276 |
+
movingUIPresets,
|
| 277 |
+
quickReplyPresets,
|
| 278 |
+
instruct,
|
| 279 |
+
context,
|
| 280 |
+
sysprompt,
|
| 281 |
+
reasoning,
|
| 282 |
+
enable_extensions: ENABLE_EXTENSIONS,
|
| 283 |
+
enable_extensions_auto_update: ENABLE_EXTENSIONS_AUTO_UPDATE,
|
| 284 |
+
enable_accounts: ENABLE_ACCOUNTS,
|
| 285 |
+
});
|
| 286 |
+
});
|
| 287 |
+
|
| 288 |
+
router.post('/get-snapshots', async (request, response) => {
|
| 289 |
+
try {
|
| 290 |
+
const snapshots = fs.readdirSync(request.user.directories.backups);
|
| 291 |
+
const userFilesPattern = getSettingsBackupFilePrefix(request.user.profile.handle);
|
| 292 |
+
const userSnapshots = snapshots.filter(x => x.startsWith(userFilesPattern));
|
| 293 |
+
|
| 294 |
+
const result = userSnapshots.map(x => {
|
| 295 |
+
const stat = fs.statSync(path.join(request.user.directories.backups, x));
|
| 296 |
+
return { date: stat.ctimeMs, name: x, size: stat.size };
|
| 297 |
+
});
|
| 298 |
+
|
| 299 |
+
response.json(result);
|
| 300 |
+
} catch (error) {
|
| 301 |
+
console.error(error);
|
| 302 |
+
response.sendStatus(500);
|
| 303 |
+
}
|
| 304 |
+
});
|
| 305 |
+
|
| 306 |
+
router.post('/load-snapshot', getFileNameValidationFunction('name'), async (request, response) => {
|
| 307 |
+
try {
|
| 308 |
+
const userFilesPattern = getSettingsBackupFilePrefix(request.user.profile.handle);
|
| 309 |
+
|
| 310 |
+
if (!request.body.name || !request.body.name.startsWith(userFilesPattern)) {
|
| 311 |
+
return response.status(400).send({ error: 'Invalid snapshot name' });
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
const snapshotName = request.body.name;
|
| 315 |
+
const snapshotPath = path.join(request.user.directories.backups, snapshotName);
|
| 316 |
+
|
| 317 |
+
if (!fs.existsSync(snapshotPath)) {
|
| 318 |
+
return response.sendStatus(404);
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
const content = fs.readFileSync(snapshotPath, 'utf8');
|
| 322 |
+
|
| 323 |
+
response.send(content);
|
| 324 |
+
} catch (error) {
|
| 325 |
+
console.error(error);
|
| 326 |
+
response.sendStatus(500);
|
| 327 |
+
}
|
| 328 |
+
});
|
| 329 |
+
|
| 330 |
+
router.post('/make-snapshot', async (request, response) => {
|
| 331 |
+
try {
|
| 332 |
+
backupUserSettings(request.user.profile.handle, false);
|
| 333 |
+
response.sendStatus(204);
|
| 334 |
+
} catch (error) {
|
| 335 |
+
console.error(error);
|
| 336 |
+
response.sendStatus(500);
|
| 337 |
+
}
|
| 338 |
+
});
|
| 339 |
+
|
| 340 |
+
router.post('/restore-snapshot', getFileNameValidationFunction('name'), async (request, response) => {
|
| 341 |
+
try {
|
| 342 |
+
const userFilesPattern = getSettingsBackupFilePrefix(request.user.profile.handle);
|
| 343 |
+
|
| 344 |
+
if (!request.body.name || !request.body.name.startsWith(userFilesPattern)) {
|
| 345 |
+
return response.status(400).send({ error: 'Invalid snapshot name' });
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
const snapshotName = request.body.name;
|
| 349 |
+
const snapshotPath = path.join(request.user.directories.backups, snapshotName);
|
| 350 |
+
|
| 351 |
+
if (!fs.existsSync(snapshotPath)) {
|
| 352 |
+
return response.sendStatus(404);
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
const pathToSettings = path.join(request.user.directories.root, SETTINGS_FILE);
|
| 356 |
+
fs.rmSync(pathToSettings, { force: true });
|
| 357 |
+
fs.copyFileSync(snapshotPath, pathToSettings);
|
| 358 |
+
|
| 359 |
+
response.sendStatus(204);
|
| 360 |
+
} catch (error) {
|
| 361 |
+
console.error(error);
|
| 362 |
+
response.sendStatus(500);
|
| 363 |
+
}
|
| 364 |
+
});
|
| 365 |
+
|
| 366 |
+
/**
|
| 367 |
+
* Initializes the settings endpoint
|
| 368 |
+
*/
|
| 369 |
+
export async function init() {
|
| 370 |
+
await backupSettings();
|
| 371 |
+
}
|
src/endpoints/speech.js
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Buffer } from 'node:buffer';
|
| 2 |
+
import fs from 'node:fs';
|
| 3 |
+
import express from 'express';
|
| 4 |
+
import wavefile from 'wavefile';
|
| 5 |
+
import fetch from 'node-fetch';
|
| 6 |
+
import FormData from 'form-data';
|
| 7 |
+
import mime from 'mime-types';
|
| 8 |
+
import { getPipeline } from '../transformers.js';
|
| 9 |
+
import { forwardFetchResponse } from '../util.js';
|
| 10 |
+
import { readSecret, SECRET_KEYS } from './secrets.js';
|
| 11 |
+
|
| 12 |
+
export const router = express.Router();
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* Gets the audio data from a base64-encoded audio file.
|
| 16 |
+
* @param {string} audio Base64-encoded audio
|
| 17 |
+
* @returns {Float64Array} Audio data
|
| 18 |
+
*/
|
| 19 |
+
function getWaveFile(audio) {
|
| 20 |
+
const wav = new wavefile.WaveFile();
|
| 21 |
+
wav.fromDataURI(audio);
|
| 22 |
+
wav.toBitDepth('32f');
|
| 23 |
+
wav.toSampleRate(16000);
|
| 24 |
+
let audioData = wav.getSamples();
|
| 25 |
+
if (Array.isArray(audioData)) {
|
| 26 |
+
if (audioData.length > 1) {
|
| 27 |
+
const SCALING_FACTOR = Math.sqrt(2);
|
| 28 |
+
|
| 29 |
+
// Merge channels (into first channel to save memory)
|
| 30 |
+
for (let i = 0; i < audioData[0].length; ++i) {
|
| 31 |
+
audioData[0][i] = SCALING_FACTOR * (audioData[0][i] + audioData[1][i]) / 2;
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
// Select first channel
|
| 36 |
+
audioData = audioData[0];
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
return audioData;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
router.post('/recognize', async (req, res) => {
|
| 43 |
+
try {
|
| 44 |
+
const TASK = 'automatic-speech-recognition';
|
| 45 |
+
const { model, audio, lang } = req.body;
|
| 46 |
+
const pipe = await getPipeline(TASK, model);
|
| 47 |
+
const wav = getWaveFile(audio);
|
| 48 |
+
const start = performance.now();
|
| 49 |
+
const result = await pipe(wav, { language: lang || null, task: 'transcribe' });
|
| 50 |
+
const end = performance.now();
|
| 51 |
+
console.info(`Execution duration: ${(end - start) / 1000} seconds`);
|
| 52 |
+
console.info('Transcribed audio:', result.text);
|
| 53 |
+
|
| 54 |
+
return res.json({ text: result.text });
|
| 55 |
+
} catch (error) {
|
| 56 |
+
console.error(error);
|
| 57 |
+
return res.sendStatus(500);
|
| 58 |
+
}
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
router.post('/synthesize', async (req, res) => {
|
| 62 |
+
try {
|
| 63 |
+
const TASK = 'text-to-speech';
|
| 64 |
+
const { text, model, speaker } = req.body;
|
| 65 |
+
const pipe = await getPipeline(TASK, model);
|
| 66 |
+
const speaker_embeddings = speaker
|
| 67 |
+
? new Float32Array(new Uint8Array(Buffer.from(speaker.startsWith('data:') ? speaker.split(',')[1] : speaker, 'base64')).buffer)
|
| 68 |
+
: null;
|
| 69 |
+
const start = performance.now();
|
| 70 |
+
const result = await pipe(text, { speaker_embeddings: speaker_embeddings });
|
| 71 |
+
const end = performance.now();
|
| 72 |
+
console.debug(`Execution duration: ${(end - start) / 1000} seconds`);
|
| 73 |
+
|
| 74 |
+
const wav = new wavefile.WaveFile();
|
| 75 |
+
wav.fromScratch(1, result.sampling_rate, '32f', result.audio);
|
| 76 |
+
const buffer = wav.toBuffer();
|
| 77 |
+
|
| 78 |
+
res.set('Content-Type', 'audio/wav');
|
| 79 |
+
return res.send(Buffer.from(buffer));
|
| 80 |
+
} catch (error) {
|
| 81 |
+
console.error(error);
|
| 82 |
+
return res.sendStatus(500);
|
| 83 |
+
}
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
const pollinations = express.Router();
|
| 87 |
+
|
| 88 |
+
pollinations.post('/voices', async (req, res) => {
|
| 89 |
+
try {
|
| 90 |
+
const model = req.body.model || 'openai-audio';
|
| 91 |
+
|
| 92 |
+
const response = await fetch('https://text.pollinations.ai/models');
|
| 93 |
+
|
| 94 |
+
if (!response.ok) {
|
| 95 |
+
throw new Error('Failed to fetch Pollinations models');
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
const data = await response.json();
|
| 99 |
+
|
| 100 |
+
if (!Array.isArray(data)) {
|
| 101 |
+
throw new Error('Invalid data format received from Pollinations');
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
const audioModelData = data.find(m => m.name === model);
|
| 105 |
+
if (!audioModelData || !Array.isArray(audioModelData.voices)) {
|
| 106 |
+
throw new Error('No voices found for the specified model');
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
const voices = audioModelData.voices;
|
| 110 |
+
return res.json(voices);
|
| 111 |
+
} catch (error) {
|
| 112 |
+
console.error(error);
|
| 113 |
+
return res.sendStatus(500);
|
| 114 |
+
}
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
pollinations.post('/generate', async (req, res) => {
|
| 118 |
+
try {
|
| 119 |
+
const text = req.body.text;
|
| 120 |
+
const model = req.body.model || 'openai-audio';
|
| 121 |
+
const voice = req.body.voice || 'alloy';
|
| 122 |
+
|
| 123 |
+
const url = new URL(`https://text.pollinations.ai/generate/${encodeURIComponent(text)}`);
|
| 124 |
+
url.searchParams.append('model', model);
|
| 125 |
+
url.searchParams.append('voice', voice);
|
| 126 |
+
url.searchParams.append('referrer', 'tavernintern');
|
| 127 |
+
console.info('Pollinations request URL:', url.toString());
|
| 128 |
+
|
| 129 |
+
const response = await fetch(url);
|
| 130 |
+
|
| 131 |
+
if (!response.ok) {
|
| 132 |
+
const text = await response.text();
|
| 133 |
+
throw new Error(`Failed to generate audio from Pollinations: ${text}`);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
res.set('Content-Type', 'audio/mpeg');
|
| 137 |
+
forwardFetchResponse(response, res);
|
| 138 |
+
} catch (error) {
|
| 139 |
+
console.error(error);
|
| 140 |
+
return res.sendStatus(500);
|
| 141 |
+
}
|
| 142 |
+
});
|
| 143 |
+
|
| 144 |
+
router.use('/pollinations', pollinations);
|
| 145 |
+
|
| 146 |
+
const elevenlabs = express.Router();
|
| 147 |
+
|
| 148 |
+
elevenlabs.post('/voices', async (req, res) => {
|
| 149 |
+
try {
|
| 150 |
+
const apiKey = readSecret(req.user.directories, SECRET_KEYS.ELEVENLABS);
|
| 151 |
+
if (!apiKey) {
|
| 152 |
+
console.warn('ElevenLabs API key not found');
|
| 153 |
+
return res.sendStatus(400);
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
const response = await fetch('https://api.elevenlabs.io/v1/voices', {
|
| 157 |
+
headers: {
|
| 158 |
+
'xi-api-key': apiKey,
|
| 159 |
+
},
|
| 160 |
+
});
|
| 161 |
+
|
| 162 |
+
if (!response.ok) {
|
| 163 |
+
const text = await response.text();
|
| 164 |
+
console.warn(`ElevenLabs voices fetch failed: HTTP ${response.status} - ${text}`);
|
| 165 |
+
return res.sendStatus(500);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
const responseJson = await response.json();
|
| 169 |
+
return res.json(responseJson);
|
| 170 |
+
} catch (error) {
|
| 171 |
+
console.error(error);
|
| 172 |
+
return res.sendStatus(500);
|
| 173 |
+
}
|
| 174 |
+
});
|
| 175 |
+
|
| 176 |
+
elevenlabs.post('/voice-settings', async (req, res) => {
|
| 177 |
+
try {
|
| 178 |
+
const apiKey = readSecret(req.user.directories, SECRET_KEYS.ELEVENLABS);
|
| 179 |
+
if (!apiKey) {
|
| 180 |
+
console.warn('ElevenLabs API key not found');
|
| 181 |
+
return res.sendStatus(400);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
const response = await fetch('https://api.elevenlabs.io/v1/voices/settings/default', {
|
| 185 |
+
headers: {
|
| 186 |
+
'xi-api-key': apiKey,
|
| 187 |
+
},
|
| 188 |
+
});
|
| 189 |
+
|
| 190 |
+
if (!response.ok) {
|
| 191 |
+
const text = await response.text();
|
| 192 |
+
console.warn(`ElevenLabs voice settings fetch failed: HTTP ${response.status} - ${text}`);
|
| 193 |
+
return res.sendStatus(500);
|
| 194 |
+
}
|
| 195 |
+
const responseJson = await response.json();
|
| 196 |
+
return res.json(responseJson);
|
| 197 |
+
} catch (error) {
|
| 198 |
+
console.error(error);
|
| 199 |
+
return res.sendStatus(500);
|
| 200 |
+
}
|
| 201 |
+
});
|
| 202 |
+
|
| 203 |
+
elevenlabs.post('/synthesize', async (req, res) => {
|
| 204 |
+
try {
|
| 205 |
+
const apiKey = readSecret(req.user.directories, SECRET_KEYS.ELEVENLABS);
|
| 206 |
+
if (!apiKey) {
|
| 207 |
+
console.warn('ElevenLabs API key not found');
|
| 208 |
+
return res.sendStatus(400);
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
const { voiceId, request } = req.body;
|
| 212 |
+
|
| 213 |
+
if (!voiceId || !request) {
|
| 214 |
+
console.warn('ElevenLabs synthesis request missing voiceId or request body');
|
| 215 |
+
return res.sendStatus(400);
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
console.debug('ElevenLabs TTS request:', request);
|
| 219 |
+
|
| 220 |
+
const response = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, {
|
| 221 |
+
method: 'POST',
|
| 222 |
+
headers: {
|
| 223 |
+
'xi-api-key': apiKey,
|
| 224 |
+
'Content-Type': 'application/json',
|
| 225 |
+
},
|
| 226 |
+
body: JSON.stringify(request),
|
| 227 |
+
});
|
| 228 |
+
|
| 229 |
+
if (!response.ok) {
|
| 230 |
+
const text = await response.text();
|
| 231 |
+
console.warn(`ElevenLabs synthesis failed: HTTP ${response.status} - ${text}`);
|
| 232 |
+
return res.sendStatus(500);
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
res.set('Content-Type', 'audio/mpeg');
|
| 236 |
+
forwardFetchResponse(response, res);
|
| 237 |
+
} catch (error) {
|
| 238 |
+
console.error(error);
|
| 239 |
+
return res.sendStatus(500);
|
| 240 |
+
}
|
| 241 |
+
});
|
| 242 |
+
|
| 243 |
+
elevenlabs.post('/history', async (req, res) => {
|
| 244 |
+
try {
|
| 245 |
+
const apiKey = readSecret(req.user.directories, SECRET_KEYS.ELEVENLABS);
|
| 246 |
+
if (!apiKey) {
|
| 247 |
+
console.warn('ElevenLabs API key not found');
|
| 248 |
+
return res.sendStatus(400);
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
const response = await fetch('https://api.elevenlabs.io/v1/history', {
|
| 252 |
+
headers: {
|
| 253 |
+
'xi-api-key': apiKey,
|
| 254 |
+
},
|
| 255 |
+
});
|
| 256 |
+
|
| 257 |
+
if (!response.ok) {
|
| 258 |
+
const text = await response.text();
|
| 259 |
+
console.warn(`ElevenLabs history fetch failed: HTTP ${response.status} - ${text}`);
|
| 260 |
+
return res.sendStatus(500);
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
const responseJson = await response.json();
|
| 264 |
+
return res.json(responseJson);
|
| 265 |
+
} catch (error) {
|
| 266 |
+
console.error(error);
|
| 267 |
+
return res.sendStatus(500);
|
| 268 |
+
}
|
| 269 |
+
});
|
| 270 |
+
|
| 271 |
+
elevenlabs.post('/history-audio', async (req, res) => {
|
| 272 |
+
try {
|
| 273 |
+
const apiKey = readSecret(req.user.directories, SECRET_KEYS.ELEVENLABS);
|
| 274 |
+
if (!apiKey) {
|
| 275 |
+
console.warn('ElevenLabs API key not found');
|
| 276 |
+
return res.sendStatus(400);
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
const { historyItemId } = req.body;
|
| 280 |
+
if (!historyItemId) {
|
| 281 |
+
console.warn('ElevenLabs history audio request missing historyItemId');
|
| 282 |
+
return res.sendStatus(400);
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
console.debug('ElevenLabs history audio request for ID:', historyItemId);
|
| 286 |
+
|
| 287 |
+
const response = await fetch(`https://api.elevenlabs.io/v1/history/${historyItemId}/audio`, {
|
| 288 |
+
headers: {
|
| 289 |
+
'xi-api-key': apiKey,
|
| 290 |
+
},
|
| 291 |
+
});
|
| 292 |
+
|
| 293 |
+
if (!response.ok) {
|
| 294 |
+
const text = await response.text();
|
| 295 |
+
console.warn(`ElevenLabs history audio fetch failed: HTTP ${response.status} - ${text}`);
|
| 296 |
+
return res.sendStatus(500);
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
res.set('Content-Type', 'audio/mpeg');
|
| 300 |
+
forwardFetchResponse(response, res);
|
| 301 |
+
} catch (error) {
|
| 302 |
+
console.error(error);
|
| 303 |
+
return res.sendStatus(500);
|
| 304 |
+
}
|
| 305 |
+
});
|
| 306 |
+
|
| 307 |
+
elevenlabs.post('/voices/add', async (req, res) => {
|
| 308 |
+
try {
|
| 309 |
+
const apiKey = readSecret(req.user.directories, SECRET_KEYS.ELEVENLABS);
|
| 310 |
+
if (!apiKey) {
|
| 311 |
+
console.warn('ElevenLabs API key not found');
|
| 312 |
+
return res.sendStatus(400);
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
const { name, description, labels, files } = req.body;
|
| 316 |
+
|
| 317 |
+
const formData = new FormData();
|
| 318 |
+
formData.append('name', name || 'Custom Voice');
|
| 319 |
+
formData.append('description', description || 'Uploaded via TavernIntern');
|
| 320 |
+
formData.append('labels', labels || '');
|
| 321 |
+
|
| 322 |
+
for (const fileData of (files || [])) {
|
| 323 |
+
const [mimeType, base64Data] = /^data:(.+);base64,(.+)$/.exec(fileData)?.slice(1) || [];
|
| 324 |
+
if (!mimeType || !base64Data) {
|
| 325 |
+
console.warn('Invalid audio file data provided for ElevenLabs voice upload');
|
| 326 |
+
continue;
|
| 327 |
+
}
|
| 328 |
+
const buffer = Buffer.from(base64Data, 'base64');
|
| 329 |
+
formData.append('files', buffer, {
|
| 330 |
+
filename: `audio.${mime.extension(mimeType) || 'wav'}`,
|
| 331 |
+
contentType: mimeType,
|
| 332 |
+
});
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
console.debug('ElevenLabs voice upload request:', { name, description, labels, files: files?.length || 0 });
|
| 336 |
+
|
| 337 |
+
const response = await fetch('https://api.elevenlabs.io/v1/voices/add', {
|
| 338 |
+
method: 'POST',
|
| 339 |
+
headers: {
|
| 340 |
+
'xi-api-key': apiKey,
|
| 341 |
+
},
|
| 342 |
+
body: formData,
|
| 343 |
+
});
|
| 344 |
+
|
| 345 |
+
if (!response.ok) {
|
| 346 |
+
const text = await response.text();
|
| 347 |
+
console.warn(`ElevenLabs voice upload failed: HTTP ${response.status} - ${text}`);
|
| 348 |
+
return res.sendStatus(500);
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
const responseJson = await response.json();
|
| 352 |
+
return res.json(responseJson);
|
| 353 |
+
} catch (error) {
|
| 354 |
+
console.error(error);
|
| 355 |
+
return res.sendStatus(500);
|
| 356 |
+
}
|
| 357 |
+
});
|
| 358 |
+
|
| 359 |
+
elevenlabs.post('/recognize', async (req, res) => {
|
| 360 |
+
try {
|
| 361 |
+
const apiKey = readSecret(req.user.directories, SECRET_KEYS.ELEVENLABS);
|
| 362 |
+
if (!apiKey) {
|
| 363 |
+
console.warn('ElevenLabs API key not found');
|
| 364 |
+
return res.sendStatus(400);
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
if (!req.file) {
|
| 368 |
+
console.warn('No audio file found');
|
| 369 |
+
return res.sendStatus(400);
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
console.info('Processing audio file with ElevenLabs', req.file.path);
|
| 373 |
+
const formData = new FormData();
|
| 374 |
+
formData.append('file', fs.createReadStream(req.file.path), { filename: 'audio.wav', contentType: 'audio/wav' });
|
| 375 |
+
formData.append('model_id', req.body.model);
|
| 376 |
+
|
| 377 |
+
const response = await fetch('https://api.elevenlabs.io/v1/speech-to-text', {
|
| 378 |
+
method: 'POST',
|
| 379 |
+
headers: {
|
| 380 |
+
'xi-api-key': apiKey,
|
| 381 |
+
},
|
| 382 |
+
body: formData,
|
| 383 |
+
});
|
| 384 |
+
|
| 385 |
+
if (!response.ok) {
|
| 386 |
+
const text = await response.text();
|
| 387 |
+
console.warn(`ElevenLabs speech recognition failed: HTTP ${response.status} - ${text}`);
|
| 388 |
+
return res.sendStatus(500);
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
fs.unlinkSync(req.file.path);
|
| 392 |
+
const responseJson = await response.json();
|
| 393 |
+
console.debug('ElevenLabs speech recognition response:', responseJson);
|
| 394 |
+
return res.json(responseJson);
|
| 395 |
+
} catch (error) {
|
| 396 |
+
console.error(error);
|
| 397 |
+
return res.sendStatus(500);
|
| 398 |
+
}
|
| 399 |
+
});
|
| 400 |
+
|
| 401 |
+
router.use('/elevenlabs', elevenlabs);
|
src/endpoints/sprites.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from 'node:fs';
|
| 2 |
+
import path from 'node:path';
|
| 3 |
+
|
| 4 |
+
import express from 'express';
|
| 5 |
+
import mime from 'mime-types';
|
| 6 |
+
import sanitize from 'sanitize-filename';
|
| 7 |
+
import { sync as writeFileAtomicSync } from 'write-file-atomic';
|
| 8 |
+
|
| 9 |
+
import { getImageBuffers } from '../util.js';
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* Gets the path to the sprites folder for the provided character name
|
| 13 |
+
* @param {import('../users.js').UserDirectoryList} directories - User directories
|
| 14 |
+
* @param {string} name - The name of the character
|
| 15 |
+
* @param {boolean} isSubfolder - Whether the name contains a subfolder
|
| 16 |
+
* @returns {string | null} The path to the sprites folder. Null if the name is invalid.
|
| 17 |
+
*/
|
| 18 |
+
function getSpritesPath(directories, name, isSubfolder) {
|
| 19 |
+
if (isSubfolder) {
|
| 20 |
+
const nameParts = name.split('/');
|
| 21 |
+
const characterName = sanitize(nameParts[0]);
|
| 22 |
+
const subfolderName = sanitize(nameParts[1]);
|
| 23 |
+
|
| 24 |
+
if (!characterName || !subfolderName) {
|
| 25 |
+
return null;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
return path.join(directories.characters, characterName, subfolderName);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
name = sanitize(name);
|
| 32 |
+
|
| 33 |
+
if (!name) {
|
| 34 |
+
return null;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
return path.join(directories.characters, name);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
/**
|
| 41 |
+
* Imports base64 encoded sprites from RisuAI character data.
|
| 42 |
+
* The sprites are saved in the character's sprites folder.
|
| 43 |
+
* The additionalAssets and emotions are removed from the data.
|
| 44 |
+
* @param {import('../users.js').UserDirectoryList} directories User directories
|
| 45 |
+
* @param {object} data RisuAI character data
|
| 46 |
+
* @returns {void}
|
| 47 |
+
*/
|
| 48 |
+
export function importRisuSprites(directories, data) {
|
| 49 |
+
try {
|
| 50 |
+
const name = data?.data?.name;
|
| 51 |
+
const risuData = data?.data?.extensions?.risuai;
|
| 52 |
+
|
| 53 |
+
// Not a Risu AI character
|
| 54 |
+
if (!risuData || !name) {
|
| 55 |
+
return;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
let images = [];
|
| 59 |
+
|
| 60 |
+
if (Array.isArray(risuData.additionalAssets)) {
|
| 61 |
+
images = images.concat(risuData.additionalAssets);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
if (Array.isArray(risuData.emotions)) {
|
| 65 |
+
images = images.concat(risuData.emotions);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
// No sprites to import
|
| 69 |
+
if (images.length === 0) {
|
| 70 |
+
return;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// Create sprites folder if it doesn't exist
|
| 74 |
+
const spritesPath = getSpritesPath(directories, name, false);
|
| 75 |
+
|
| 76 |
+
// Invalid sprites path
|
| 77 |
+
if (!spritesPath) {
|
| 78 |
+
return;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// Create sprites folder if it doesn't exist
|
| 82 |
+
if (!fs.existsSync(spritesPath)) {
|
| 83 |
+
fs.mkdirSync(spritesPath, { recursive: true });
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// Path to sprites is not a directory. This should never happen.
|
| 87 |
+
if (!fs.statSync(spritesPath).isDirectory()) {
|
| 88 |
+
return;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
console.info(`RisuAI: Found ${images.length} sprites for ${name}. Writing to disk.`);
|
| 92 |
+
const files = fs.readdirSync(spritesPath);
|
| 93 |
+
|
| 94 |
+
outer: for (const [label, fileBase64] of images) {
|
| 95 |
+
// Remove existing sprite with the same label
|
| 96 |
+
for (const file of files) {
|
| 97 |
+
if (path.parse(file).name === label) {
|
| 98 |
+
console.warn(`RisuAI: The sprite ${label} for ${name} already exists. Skipping.`);
|
| 99 |
+
continue outer;
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
const filename = label + '.png';
|
| 104 |
+
const pathToFile = path.join(spritesPath, sanitize(filename));
|
| 105 |
+
writeFileAtomicSync(pathToFile, fileBase64, { encoding: 'base64' });
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
// Remove additionalAssets and emotions from data (they are now in the sprites folder)
|
| 109 |
+
delete data.data.extensions.risuai.additionalAssets;
|
| 110 |
+
delete data.data.extensions.risuai.emotions;
|
| 111 |
+
} catch (error) {
|
| 112 |
+
console.error(error);
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
export const router = express.Router();
|
| 117 |
+
|
| 118 |
+
router.get('/get', function (request, response) {
|
| 119 |
+
const name = String(request.query.name);
|
| 120 |
+
const isSubfolder = name.includes('/');
|
| 121 |
+
const spritesPath = getSpritesPath(request.user.directories, name, isSubfolder);
|
| 122 |
+
let sprites = [];
|
| 123 |
+
|
| 124 |
+
try {
|
| 125 |
+
if (spritesPath && fs.existsSync(spritesPath) && fs.statSync(spritesPath).isDirectory()) {
|
| 126 |
+
sprites = fs.readdirSync(spritesPath)
|
| 127 |
+
.filter(file => {
|
| 128 |
+
const mimeType = mime.lookup(file);
|
| 129 |
+
return mimeType && mimeType.startsWith('image/');
|
| 130 |
+
})
|
| 131 |
+
.map((file) => {
|
| 132 |
+
const pathToSprite = path.join(spritesPath, file);
|
| 133 |
+
const mtime = fs.statSync(pathToSprite).mtime?.toISOString().replace(/[^0-9]/g, '').slice(0, 14);
|
| 134 |
+
|
| 135 |
+
const fileName = path.parse(pathToSprite).name.toLowerCase();
|
| 136 |
+
// Extract the label from the filename via regex, which can be suffixed with a sub-name, either connected with a dash or a dot.
|
| 137 |
+
// Examples: joy.png, joy-1.png, joy.expressive.png
|
| 138 |
+
const label = fileName.match(/^(.+?)(?:[-\\.].*?)?$/)?.[1] ?? fileName;
|
| 139 |
+
|
| 140 |
+
return {
|
| 141 |
+
label: label,
|
| 142 |
+
path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''),
|
| 143 |
+
};
|
| 144 |
+
});
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
catch (err) {
|
| 148 |
+
console.error(err);
|
| 149 |
+
}
|
| 150 |
+
return response.send(sprites);
|
| 151 |
+
});
|
| 152 |
+
|
| 153 |
+
router.post('/delete', async (request, response) => {
|
| 154 |
+
const label = request.body.label;
|
| 155 |
+
const name = String(request.body.name);
|
| 156 |
+
const isSubfolder = name.includes('/');
|
| 157 |
+
const spriteName = request.body.spriteName || label;
|
| 158 |
+
|
| 159 |
+
if (!spriteName || !name) {
|
| 160 |
+
return response.sendStatus(400);
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
try {
|
| 164 |
+
const spritesPath = getSpritesPath(request.user.directories, name, isSubfolder);
|
| 165 |
+
|
| 166 |
+
// No sprites folder exists, or not a directory
|
| 167 |
+
if (!spritesPath || !fs.existsSync(spritesPath) || !fs.statSync(spritesPath).isDirectory()) {
|
| 168 |
+
return response.sendStatus(404);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
const files = fs.readdirSync(spritesPath);
|
| 172 |
+
|
| 173 |
+
// Remove existing sprite with the same label
|
| 174 |
+
for (const file of files) {
|
| 175 |
+
if (path.parse(file).name === spriteName) {
|
| 176 |
+
fs.unlinkSync(path.join(spritesPath, file));
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
return response.sendStatus(200);
|
| 181 |
+
} catch (error) {
|
| 182 |
+
console.error(error);
|
| 183 |
+
return response.sendStatus(500);
|
| 184 |
+
}
|
| 185 |
+
});
|
| 186 |
+
|
| 187 |
+
router.post('/upload-zip', async (request, response) => {
|
| 188 |
+
const file = request.file;
|
| 189 |
+
const name = String(request.body.name);
|
| 190 |
+
const isSubfolder = name.includes('/');
|
| 191 |
+
|
| 192 |
+
if (!file || !name) {
|
| 193 |
+
return response.sendStatus(400);
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
try {
|
| 197 |
+
const spritesPath = getSpritesPath(request.user.directories, name, isSubfolder);
|
| 198 |
+
|
| 199 |
+
// Invalid sprites path
|
| 200 |
+
if (!spritesPath) {
|
| 201 |
+
return response.sendStatus(400);
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
// Create sprites folder if it doesn't exist
|
| 205 |
+
if (!fs.existsSync(spritesPath)) {
|
| 206 |
+
fs.mkdirSync(spritesPath, { recursive: true });
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
// Path to sprites is not a directory. This should never happen.
|
| 210 |
+
if (!fs.statSync(spritesPath).isDirectory()) {
|
| 211 |
+
return response.sendStatus(404);
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
const spritePackPath = path.join(file.destination, file.filename);
|
| 215 |
+
const sprites = await getImageBuffers(spritePackPath);
|
| 216 |
+
const files = fs.readdirSync(spritesPath);
|
| 217 |
+
|
| 218 |
+
for (const [filename, buffer] of sprites) {
|
| 219 |
+
// Remove existing sprite with the same label
|
| 220 |
+
const existingFile = files.find(file => path.parse(file).name === path.parse(filename).name);
|
| 221 |
+
|
| 222 |
+
if (existingFile) {
|
| 223 |
+
fs.unlinkSync(path.join(spritesPath, existingFile));
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
// Write sprite buffer to disk
|
| 227 |
+
const pathToSprite = path.join(spritesPath, sanitize(filename));
|
| 228 |
+
writeFileAtomicSync(pathToSprite, buffer);
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
// Remove uploaded ZIP file
|
| 232 |
+
fs.unlinkSync(spritePackPath);
|
| 233 |
+
return response.send({ ok: true, count: sprites.length });
|
| 234 |
+
} catch (error) {
|
| 235 |
+
console.error(error);
|
| 236 |
+
return response.sendStatus(500);
|
| 237 |
+
}
|
| 238 |
+
});
|
| 239 |
+
|
| 240 |
+
router.post('/upload', async (request, response) => {
|
| 241 |
+
const file = request.file;
|
| 242 |
+
const label = request.body.label;
|
| 243 |
+
const name = String(request.body.name);
|
| 244 |
+
const isSubfolder = name.includes('/');
|
| 245 |
+
const spriteName = request.body.spriteName || label;
|
| 246 |
+
|
| 247 |
+
if (!file || !label || !name) {
|
| 248 |
+
return response.sendStatus(400);
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
try {
|
| 252 |
+
const spritesPath = getSpritesPath(request.user.directories, name, isSubfolder);
|
| 253 |
+
|
| 254 |
+
// Invalid sprites path
|
| 255 |
+
if (!spritesPath) {
|
| 256 |
+
return response.sendStatus(400);
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
// Create sprites folder if it doesn't exist
|
| 260 |
+
if (!fs.existsSync(spritesPath)) {
|
| 261 |
+
fs.mkdirSync(spritesPath, { recursive: true });
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
// Path to sprites is not a directory. This should never happen.
|
| 265 |
+
if (!fs.statSync(spritesPath).isDirectory()) {
|
| 266 |
+
return response.sendStatus(404);
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
const files = fs.readdirSync(spritesPath);
|
| 270 |
+
|
| 271 |
+
// Remove existing sprite with the same label
|
| 272 |
+
for (const file of files) {
|
| 273 |
+
if (path.parse(file).name === spriteName) {
|
| 274 |
+
fs.unlinkSync(path.join(spritesPath, file));
|
| 275 |
+
}
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
const filename = spriteName + path.parse(file.originalname).ext;
|
| 279 |
+
const spritePath = path.join(file.destination, file.filename);
|
| 280 |
+
const pathToFile = path.join(spritesPath, sanitize(filename));
|
| 281 |
+
// Copy uploaded file to sprites folder
|
| 282 |
+
fs.cpSync(spritePath, pathToFile);
|
| 283 |
+
// Remove uploaded file
|
| 284 |
+
fs.unlinkSync(spritePath);
|
| 285 |
+
return response.send({ ok: true });
|
| 286 |
+
} catch (error) {
|
| 287 |
+
console.error(error);
|
| 288 |
+
return response.sendStatus(500);
|
| 289 |
+
}
|
| 290 |
+
});
|
src/endpoints/stable-diffusion.js
ADDED
|
@@ -0,0 +1,1822 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from 'node:fs';
|
| 2 |
+
import path from 'node:path';
|
| 3 |
+
|
| 4 |
+
import express from 'express';
|
| 5 |
+
import fetch from 'node-fetch';
|
| 6 |
+
import sanitize from 'sanitize-filename';
|
| 7 |
+
import { sync as writeFileAtomicSync } from 'write-file-atomic';
|
| 8 |
+
import FormData from 'form-data';
|
| 9 |
+
import urlJoin from 'url-join';
|
| 10 |
+
import _ from 'lodash';
|
| 11 |
+
|
| 12 |
+
import { delay, getBasicAuthHeader, isValidUrl, tryParse } from '../util.js';
|
| 13 |
+
import { readSecret, SECRET_KEYS } from './secrets.js';
|
| 14 |
+
import { AIMLAPI_HEADERS } from '../constants.js';
|
| 15 |
+
|
| 16 |
+
/**
|
| 17 |
+
* Gets the comfy workflows.
|
| 18 |
+
* @param {import('../users.js').UserDirectoryList} directories
|
| 19 |
+
* @returns {string[]} List of comfy workflows
|
| 20 |
+
*/
|
| 21 |
+
function getComfyWorkflows(directories) {
|
| 22 |
+
return fs
|
| 23 |
+
.readdirSync(directories.comfyWorkflows)
|
| 24 |
+
.filter(file => file[0] !== '.' && file.toLowerCase().endsWith('.json'))
|
| 25 |
+
.sort(Intl.Collator().compare);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export const router = express.Router();
|
| 29 |
+
|
| 30 |
+
router.post('/ping', async (request, response) => {
|
| 31 |
+
try {
|
| 32 |
+
const url = new URL(request.body.url);
|
| 33 |
+
url.pathname = '/sdapi/v1/options';
|
| 34 |
+
|
| 35 |
+
const result = await fetch(url, {
|
| 36 |
+
method: 'GET',
|
| 37 |
+
headers: {
|
| 38 |
+
'Authorization': getBasicAuthHeader(request.body.auth),
|
| 39 |
+
},
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
if (!result.ok) {
|
| 43 |
+
throw new Error('SD WebUI returned an error.');
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
return response.sendStatus(200);
|
| 47 |
+
} catch (error) {
|
| 48 |
+
console.error(error);
|
| 49 |
+
return response.sendStatus(500);
|
| 50 |
+
}
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
router.post('/upscalers', async (request, response) => {
|
| 54 |
+
try {
|
| 55 |
+
async function getUpscalerModels() {
|
| 56 |
+
const url = new URL(request.body.url);
|
| 57 |
+
url.pathname = '/sdapi/v1/upscalers';
|
| 58 |
+
|
| 59 |
+
const result = await fetch(url, {
|
| 60 |
+
method: 'GET',
|
| 61 |
+
headers: {
|
| 62 |
+
'Authorization': getBasicAuthHeader(request.body.auth),
|
| 63 |
+
},
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
if (!result.ok) {
|
| 67 |
+
throw new Error('SD WebUI returned an error.');
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/** @type {any} */
|
| 71 |
+
const data = await result.json();
|
| 72 |
+
return data.map(x => x.name);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
async function getLatentUpscalers() {
|
| 76 |
+
const url = new URL(request.body.url);
|
| 77 |
+
url.pathname = '/sdapi/v1/latent-upscale-modes';
|
| 78 |
+
|
| 79 |
+
const result = await fetch(url, {
|
| 80 |
+
method: 'GET',
|
| 81 |
+
headers: {
|
| 82 |
+
'Authorization': getBasicAuthHeader(request.body.auth),
|
| 83 |
+
},
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
if (!result.ok) {
|
| 87 |
+
throw new Error('SD WebUI returned an error.');
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
/** @type {any} */
|
| 91 |
+
const data = await result.json();
|
| 92 |
+
return data.map(x => x.name);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
const [upscalers, latentUpscalers] = await Promise.all([getUpscalerModels(), getLatentUpscalers()]);
|
| 96 |
+
|
| 97 |
+
// 0 = None, then Latent Upscalers, then Upscalers
|
| 98 |
+
upscalers.splice(1, 0, ...latentUpscalers);
|
| 99 |
+
|
| 100 |
+
return response.send(upscalers);
|
| 101 |
+
} catch (error) {
|
| 102 |
+
console.error(error);
|
| 103 |
+
return response.sendStatus(500);
|
| 104 |
+
}
|
| 105 |
+
});
|
| 106 |
+
|
| 107 |
+
router.post('/vaes', async (request, response) => {
|
| 108 |
+
try {
|
| 109 |
+
const autoUrl = new URL(request.body.url);
|
| 110 |
+
autoUrl.pathname = '/sdapi/v1/sd-vae';
|
| 111 |
+
const forgeUrl = new URL(request.body.url);
|
| 112 |
+
forgeUrl.pathname = '/sdapi/v1/sd-modules';
|
| 113 |
+
|
| 114 |
+
const requestInit = {
|
| 115 |
+
method: 'GET',
|
| 116 |
+
headers: {
|
| 117 |
+
'Authorization': getBasicAuthHeader(request.body.auth),
|
| 118 |
+
},
|
| 119 |
+
};
|
| 120 |
+
const results = await Promise.allSettled([
|
| 121 |
+
fetch(autoUrl, requestInit).then(r => r.ok ? r.json() : Promise.reject(r.statusText)),
|
| 122 |
+
fetch(forgeUrl, requestInit).then(r => r.ok ? r.json() : Promise.reject(r.statusText)),
|
| 123 |
+
]);
|
| 124 |
+
|
| 125 |
+
const data = results.find(r => r.status === 'fulfilled')?.value;
|
| 126 |
+
|
| 127 |
+
if (!Array.isArray(data)) {
|
| 128 |
+
throw new Error('SD WebUI returned an error.');
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
const names = data.map(x => x.model_name);
|
| 132 |
+
return response.send(names);
|
| 133 |
+
} catch (error) {
|
| 134 |
+
console.error(error);
|
| 135 |
+
return response.sendStatus(500);
|
| 136 |
+
}
|
| 137 |
+
});
|
| 138 |
+
|
| 139 |
+
router.post('/samplers', async (request, response) => {
|
| 140 |
+
try {
|
| 141 |
+
const url = new URL(request.body.url);
|
| 142 |
+
url.pathname = '/sdapi/v1/samplers';
|
| 143 |
+
|
| 144 |
+
const result = await fetch(url, {
|
| 145 |
+
method: 'GET',
|
| 146 |
+
headers: {
|
| 147 |
+
'Authorization': getBasicAuthHeader(request.body.auth),
|
| 148 |
+
},
|
| 149 |
+
});
|
| 150 |
+
|
| 151 |
+
if (!result.ok) {
|
| 152 |
+
throw new Error('SD WebUI returned an error.');
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
/** @type {any} */
|
| 156 |
+
const data = await result.json();
|
| 157 |
+
const names = data.map(x => x.name);
|
| 158 |
+
return response.send(names);
|
| 159 |
+
|
| 160 |
+
} catch (error) {
|
| 161 |
+
console.error(error);
|
| 162 |
+
return response.sendStatus(500);
|
| 163 |
+
}
|
| 164 |
+
});
|
| 165 |
+
|
| 166 |
+
router.post('/schedulers', async (request, response) => {
|
| 167 |
+
try {
|
| 168 |
+
const url = new URL(request.body.url);
|
| 169 |
+
url.pathname = '/sdapi/v1/schedulers';
|
| 170 |
+
|
| 171 |
+
const result = await fetch(url, {
|
| 172 |
+
method: 'GET',
|
| 173 |
+
headers: {
|
| 174 |
+
'Authorization': getBasicAuthHeader(request.body.auth),
|
| 175 |
+
},
|
| 176 |
+
});
|
| 177 |
+
|
| 178 |
+
if (!result.ok) {
|
| 179 |
+
throw new Error('SD WebUI returned an error.');
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
/** @type {any} */
|
| 183 |
+
const data = await result.json();
|
| 184 |
+
const names = data.map(x => x.name);
|
| 185 |
+
return response.send(names);
|
| 186 |
+
} catch (error) {
|
| 187 |
+
console.error(error);
|
| 188 |
+
return response.sendStatus(500);
|
| 189 |
+
}
|
| 190 |
+
});
|
| 191 |
+
|
| 192 |
+
router.post('/models', async (request, response) => {
|
| 193 |
+
try {
|
| 194 |
+
const url = new URL(request.body.url);
|
| 195 |
+
url.pathname = '/sdapi/v1/sd-models';
|
| 196 |
+
|
| 197 |
+
const result = await fetch(url, {
|
| 198 |
+
method: 'GET',
|
| 199 |
+
headers: {
|
| 200 |
+
'Authorization': getBasicAuthHeader(request.body.auth),
|
| 201 |
+
},
|
| 202 |
+
});
|
| 203 |
+
|
| 204 |
+
if (!result.ok) {
|
| 205 |
+
throw new Error('SD WebUI returned an error.');
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
/** @type {any} */
|
| 209 |
+
const data = await result.json();
|
| 210 |
+
const models = data.map(x => ({ value: x.title, text: x.title }));
|
| 211 |
+
return response.send(models);
|
| 212 |
+
} catch (error) {
|
| 213 |
+
console.error(error);
|
| 214 |
+
return response.sendStatus(500);
|
| 215 |
+
}
|
| 216 |
+
});
|
| 217 |
+
|
| 218 |
+
router.post('/get-model', async (request, response) => {
|
| 219 |
+
try {
|
| 220 |
+
const url = new URL(request.body.url);
|
| 221 |
+
url.pathname = '/sdapi/v1/options';
|
| 222 |
+
|
| 223 |
+
const result = await fetch(url, {
|
| 224 |
+
method: 'GET',
|
| 225 |
+
headers: {
|
| 226 |
+
'Authorization': getBasicAuthHeader(request.body.auth),
|
| 227 |
+
},
|
| 228 |
+
});
|
| 229 |
+
/** @type {any} */
|
| 230 |
+
const data = await result.json();
|
| 231 |
+
return response.send(data['sd_model_checkpoint']);
|
| 232 |
+
} catch (error) {
|
| 233 |
+
console.error(error);
|
| 234 |
+
return response.sendStatus(500);
|
| 235 |
+
}
|
| 236 |
+
});
|
| 237 |
+
|
| 238 |
+
router.post('/set-model', async (request, response) => {
|
| 239 |
+
try {
|
| 240 |
+
async function getProgress() {
|
| 241 |
+
const url = new URL(request.body.url);
|
| 242 |
+
url.pathname = '/sdapi/v1/progress';
|
| 243 |
+
|
| 244 |
+
const result = await fetch(url, {
|
| 245 |
+
method: 'GET',
|
| 246 |
+
headers: {
|
| 247 |
+
'Authorization': getBasicAuthHeader(request.body.auth),
|
| 248 |
+
},
|
| 249 |
+
});
|
| 250 |
+
return await result.json();
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
const url = new URL(request.body.url);
|
| 254 |
+
url.pathname = '/sdapi/v1/options';
|
| 255 |
+
|
| 256 |
+
const options = {
|
| 257 |
+
sd_model_checkpoint: request.body.model,
|
| 258 |
+
};
|
| 259 |
+
|
| 260 |
+
const result = await fetch(url, {
|
| 261 |
+
method: 'POST',
|
| 262 |
+
body: JSON.stringify(options),
|
| 263 |
+
headers: {
|
| 264 |
+
'Content-Type': 'application/json',
|
| 265 |
+
'Authorization': getBasicAuthHeader(request.body.auth),
|
| 266 |
+
},
|
| 267 |
+
});
|
| 268 |
+
|
| 269 |
+
if (!result.ok) {
|
| 270 |
+
throw new Error('SD WebUI returned an error.');
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
const MAX_ATTEMPTS = 10;
|
| 274 |
+
const CHECK_INTERVAL = 2000;
|
| 275 |
+
|
| 276 |
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
| 277 |
+
/** @type {any} */
|
| 278 |
+
const progressState = await getProgress();
|
| 279 |
+
|
| 280 |
+
const progress = progressState['progress'];
|
| 281 |
+
const jobCount = progressState['state']['job_count'];
|
| 282 |
+
if (progress === 0.0 && jobCount === 0) {
|
| 283 |
+
break;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
console.info(`Waiting for SD WebUI to finish model loading... Progress: ${progress}; Job count: ${jobCount}`);
|
| 287 |
+
await delay(CHECK_INTERVAL);
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
return response.sendStatus(200);
|
| 291 |
+
} catch (error) {
|
| 292 |
+
console.error(error);
|
| 293 |
+
return response.sendStatus(500);
|
| 294 |
+
}
|
| 295 |
+
});
|
| 296 |
+
|
| 297 |
+
router.post('/generate', async (request, response) => {
|
| 298 |
+
try {
|
| 299 |
+
try {
|
| 300 |
+
const optionsUrl = new URL(request.body.url);
|
| 301 |
+
optionsUrl.pathname = '/sdapi/v1/options';
|
| 302 |
+
const optionsResult = await fetch(optionsUrl, { headers: { 'Authorization': getBasicAuthHeader(request.body.auth) } });
|
| 303 |
+
if (optionsResult.ok) {
|
| 304 |
+
const optionsData = /** @type {any} */ (await optionsResult.json());
|
| 305 |
+
const isForge = 'forge_preset' in optionsData;
|
| 306 |
+
|
| 307 |
+
if (!isForge) {
|
| 308 |
+
_.unset(request.body, 'override_settings.forge_additional_modules');
|
| 309 |
+
}
|
| 310 |
+
}
|
| 311 |
+
} catch (error) {
|
| 312 |
+
console.error('SD WebUI failed to get options:', error);
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
const controller = new AbortController();
|
| 316 |
+
request.socket.removeAllListeners('close');
|
| 317 |
+
request.socket.on('close', function () {
|
| 318 |
+
if (!response.writableEnded) {
|
| 319 |
+
const interruptUrl = new URL(request.body.url);
|
| 320 |
+
interruptUrl.pathname = '/sdapi/v1/interrupt';
|
| 321 |
+
fetch(interruptUrl, { method: 'POST', headers: { 'Authorization': getBasicAuthHeader(request.body.auth) } });
|
| 322 |
+
}
|
| 323 |
+
controller.abort();
|
| 324 |
+
});
|
| 325 |
+
|
| 326 |
+
console.debug('SD WebUI request:', request.body);
|
| 327 |
+
const txt2imgUrl = new URL(request.body.url);
|
| 328 |
+
txt2imgUrl.pathname = '/sdapi/v1/txt2img';
|
| 329 |
+
const result = await fetch(txt2imgUrl, {
|
| 330 |
+
method: 'POST',
|
| 331 |
+
body: JSON.stringify(request.body),
|
| 332 |
+
headers: {
|
| 333 |
+
'Content-Type': 'application/json',
|
| 334 |
+
'Authorization': getBasicAuthHeader(request.body.auth),
|
| 335 |
+
},
|
| 336 |
+
signal: controller.signal,
|
| 337 |
+
});
|
| 338 |
+
|
| 339 |
+
if (!result.ok) {
|
| 340 |
+
const text = await result.text();
|
| 341 |
+
throw new Error('SD WebUI returned an error.', { cause: text });
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
const data = await result.json();
|
| 345 |
+
return response.send(data);
|
| 346 |
+
} catch (error) {
|
| 347 |
+
console.error(error);
|
| 348 |
+
return response.sendStatus(500);
|
| 349 |
+
}
|
| 350 |
+
});
|
| 351 |
+
|
| 352 |
+
router.post('/sd-next/upscalers', async (request, response) => {
|
| 353 |
+
try {
|
| 354 |
+
const url = new URL(request.body.url);
|
| 355 |
+
url.pathname = '/sdapi/v1/upscalers';
|
| 356 |
+
|
| 357 |
+
const result = await fetch(url, {
|
| 358 |
+
method: 'GET',
|
| 359 |
+
headers: {
|
| 360 |
+
'Authorization': getBasicAuthHeader(request.body.auth),
|
| 361 |
+
},
|
| 362 |
+
});
|
| 363 |
+
|
| 364 |
+
if (!result.ok) {
|
| 365 |
+
throw new Error('SD WebUI returned an error.');
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
// Vlad doesn't provide Latent Upscalers in the API, so we have to hardcode them here
|
| 369 |
+
const latentUpscalers = ['Latent', 'Latent (antialiased)', 'Latent (bicubic)', 'Latent (bicubic antialiased)', 'Latent (nearest)', 'Latent (nearest-exact)'];
|
| 370 |
+
|
| 371 |
+
/** @type {any} */
|
| 372 |
+
const data = await result.json();
|
| 373 |
+
const names = data.map(x => x.name);
|
| 374 |
+
|
| 375 |
+
// 0 = None, then Latent Upscalers, then Upscalers
|
| 376 |
+
names.splice(1, 0, ...latentUpscalers);
|
| 377 |
+
|
| 378 |
+
return response.send(names);
|
| 379 |
+
} catch (error) {
|
| 380 |
+
console.error(error);
|
| 381 |
+
return response.sendStatus(500);
|
| 382 |
+
}
|
| 383 |
+
});
|
| 384 |
+
|
| 385 |
+
const comfy = express.Router();
|
| 386 |
+
|
| 387 |
+
comfy.post('/ping', async (request, response) => {
|
| 388 |
+
try {
|
| 389 |
+
const url = new URL(urlJoin(request.body.url, '/system_stats'));
|
| 390 |
+
|
| 391 |
+
const result = await fetch(url);
|
| 392 |
+
if (!result.ok) {
|
| 393 |
+
throw new Error('ComfyUI returned an error.');
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
return response.sendStatus(200);
|
| 397 |
+
} catch (error) {
|
| 398 |
+
console.error(error);
|
| 399 |
+
return response.sendStatus(500);
|
| 400 |
+
}
|
| 401 |
+
});
|
| 402 |
+
|
| 403 |
+
comfy.post('/samplers', async (request, response) => {
|
| 404 |
+
try {
|
| 405 |
+
const url = new URL(urlJoin(request.body.url, '/object_info'));
|
| 406 |
+
|
| 407 |
+
const result = await fetch(url);
|
| 408 |
+
if (!result.ok) {
|
| 409 |
+
throw new Error('ComfyUI returned an error.');
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
/** @type {any} */
|
| 413 |
+
const data = await result.json();
|
| 414 |
+
return response.send(data.KSampler.input.required.sampler_name[0]);
|
| 415 |
+
} catch (error) {
|
| 416 |
+
console.error(error);
|
| 417 |
+
return response.sendStatus(500);
|
| 418 |
+
}
|
| 419 |
+
});
|
| 420 |
+
|
| 421 |
+
comfy.post('/models', async (request, response) => {
|
| 422 |
+
try {
|
| 423 |
+
const url = new URL(urlJoin(request.body.url, '/object_info'));
|
| 424 |
+
|
| 425 |
+
const result = await fetch(url);
|
| 426 |
+
if (!result.ok) {
|
| 427 |
+
throw new Error('ComfyUI returned an error.');
|
| 428 |
+
}
|
| 429 |
+
/** @type {any} */
|
| 430 |
+
const data = await result.json();
|
| 431 |
+
|
| 432 |
+
const ckpts = data.CheckpointLoaderSimple.input.required.ckpt_name[0].map(it => ({ value: it, text: it })) || [];
|
| 433 |
+
const unets = data.UNETLoader.input.required.unet_name[0].map(it => ({ value: it, text: `UNet: ${it}` })) || [];
|
| 434 |
+
|
| 435 |
+
// load list of GGUF unets from diffusion_models if the loader node is available
|
| 436 |
+
const ggufs = data.UnetLoaderGGUF?.input.required.unet_name[0].map(it => ({ value: it, text: `GGUF: ${it}` })) || [];
|
| 437 |
+
const models = [...ckpts, ...unets, ...ggufs];
|
| 438 |
+
|
| 439 |
+
// make the display names of the models somewhat presentable
|
| 440 |
+
models.forEach(it => it.text = it.text.replace(/\.[^.]*$/, '').replace(/_/g, ' '));
|
| 441 |
+
|
| 442 |
+
return response.send(models);
|
| 443 |
+
} catch (error) {
|
| 444 |
+
console.error(error);
|
| 445 |
+
return response.sendStatus(500);
|
| 446 |
+
}
|
| 447 |
+
});
|
| 448 |
+
|
| 449 |
+
comfy.post('/schedulers', async (request, response) => {
|
| 450 |
+
try {
|
| 451 |
+
const url = new URL(urlJoin(request.body.url, '/object_info'));
|
| 452 |
+
|
| 453 |
+
const result = await fetch(url);
|
| 454 |
+
if (!result.ok) {
|
| 455 |
+
throw new Error('ComfyUI returned an error.');
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
/** @type {any} */
|
| 459 |
+
const data = await result.json();
|
| 460 |
+
return response.send(data.KSampler.input.required.scheduler[0]);
|
| 461 |
+
} catch (error) {
|
| 462 |
+
console.error(error);
|
| 463 |
+
return response.sendStatus(500);
|
| 464 |
+
}
|
| 465 |
+
});
|
| 466 |
+
|
| 467 |
+
comfy.post('/vaes', async (request, response) => {
|
| 468 |
+
try {
|
| 469 |
+
const url = new URL(urlJoin(request.body.url, '/object_info'));
|
| 470 |
+
|
| 471 |
+
const result = await fetch(url);
|
| 472 |
+
if (!result.ok) {
|
| 473 |
+
throw new Error('ComfyUI returned an error.');
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
/** @type {any} */
|
| 477 |
+
const data = await result.json();
|
| 478 |
+
return response.send(data.VAELoader.input.required.vae_name[0]);
|
| 479 |
+
} catch (error) {
|
| 480 |
+
console.error(error);
|
| 481 |
+
return response.sendStatus(500);
|
| 482 |
+
}
|
| 483 |
+
});
|
| 484 |
+
|
| 485 |
+
comfy.post('/workflows', async (request, response) => {
|
| 486 |
+
try {
|
| 487 |
+
const data = getComfyWorkflows(request.user.directories);
|
| 488 |
+
return response.send(data);
|
| 489 |
+
} catch (error) {
|
| 490 |
+
console.error(error);
|
| 491 |
+
return response.sendStatus(500);
|
| 492 |
+
}
|
| 493 |
+
});
|
| 494 |
+
|
| 495 |
+
comfy.post('/workflow', async (request, response) => {
|
| 496 |
+
try {
|
| 497 |
+
let filePath = path.join(request.user.directories.comfyWorkflows, sanitize(String(request.body.file_name)));
|
| 498 |
+
if (!fs.existsSync(filePath)) {
|
| 499 |
+
filePath = path.join(request.user.directories.comfyWorkflows, 'Default_Comfy_Workflow.json');
|
| 500 |
+
}
|
| 501 |
+
const data = fs.readFileSync(filePath, { encoding: 'utf-8' });
|
| 502 |
+
return response.send(JSON.stringify(data));
|
| 503 |
+
} catch (error) {
|
| 504 |
+
console.error(error);
|
| 505 |
+
return response.sendStatus(500);
|
| 506 |
+
}
|
| 507 |
+
});
|
| 508 |
+
|
| 509 |
+
comfy.post('/save-workflow', async (request, response) => {
|
| 510 |
+
try {
|
| 511 |
+
const filePath = path.join(request.user.directories.comfyWorkflows, sanitize(String(request.body.file_name)));
|
| 512 |
+
writeFileAtomicSync(filePath, request.body.workflow, 'utf8');
|
| 513 |
+
const data = getComfyWorkflows(request.user.directories);
|
| 514 |
+
return response.send(data);
|
| 515 |
+
} catch (error) {
|
| 516 |
+
console.error(error);
|
| 517 |
+
return response.sendStatus(500);
|
| 518 |
+
}
|
| 519 |
+
});
|
| 520 |
+
|
| 521 |
+
comfy.post('/delete-workflow', async (request, response) => {
|
| 522 |
+
try {
|
| 523 |
+
const filePath = path.join(request.user.directories.comfyWorkflows, sanitize(String(request.body.file_name)));
|
| 524 |
+
if (fs.existsSync(filePath)) {
|
| 525 |
+
fs.unlinkSync(filePath);
|
| 526 |
+
}
|
| 527 |
+
return response.sendStatus(200);
|
| 528 |
+
} catch (error) {
|
| 529 |
+
console.error(error);
|
| 530 |
+
return response.sendStatus(500);
|
| 531 |
+
}
|
| 532 |
+
});
|
| 533 |
+
|
| 534 |
+
comfy.post('/generate', async (request, response) => {
|
| 535 |
+
try {
|
| 536 |
+
let item;
|
| 537 |
+
const url = new URL(urlJoin(request.body.url, '/prompt'));
|
| 538 |
+
|
| 539 |
+
const controller = new AbortController();
|
| 540 |
+
request.socket.removeAllListeners('close');
|
| 541 |
+
request.socket.on('close', function () {
|
| 542 |
+
if (!response.writableEnded && !item) {
|
| 543 |
+
const interruptUrl = new URL(urlJoin(request.body.url, '/interrupt'));
|
| 544 |
+
fetch(interruptUrl, { method: 'POST', headers: { 'Authorization': getBasicAuthHeader(request.body.auth) } });
|
| 545 |
+
}
|
| 546 |
+
controller.abort();
|
| 547 |
+
});
|
| 548 |
+
|
| 549 |
+
const promptResult = await fetch(url, {
|
| 550 |
+
method: 'POST',
|
| 551 |
+
body: request.body.prompt,
|
| 552 |
+
});
|
| 553 |
+
if (!promptResult.ok) {
|
| 554 |
+
const text = await promptResult.text();
|
| 555 |
+
throw new Error('ComfyUI returned an error.', { cause: tryParse(text) });
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
/** @type {any} */
|
| 559 |
+
const data = await promptResult.json();
|
| 560 |
+
const id = data.prompt_id;
|
| 561 |
+
const historyUrl = new URL(urlJoin(request.body.url, '/history'));
|
| 562 |
+
while (true) {
|
| 563 |
+
const result = await fetch(historyUrl);
|
| 564 |
+
if (!result.ok) {
|
| 565 |
+
throw new Error('ComfyUI returned an error.');
|
| 566 |
+
}
|
| 567 |
+
/** @type {any} */
|
| 568 |
+
const history = await result.json();
|
| 569 |
+
item = history[id];
|
| 570 |
+
if (item) {
|
| 571 |
+
break;
|
| 572 |
+
}
|
| 573 |
+
await delay(100);
|
| 574 |
+
}
|
| 575 |
+
if (item.status.status_str === 'error') {
|
| 576 |
+
// Report node tracebacks if available
|
| 577 |
+
const errorMessages = item.status?.messages
|
| 578 |
+
?.filter(it => it[0] === 'execution_error')
|
| 579 |
+
.map(it => it[1])
|
| 580 |
+
.map(it => `${it.node_type} [${it.node_id}] ${it.exception_type}: ${it.exception_message}`)
|
| 581 |
+
.join('\n') || '';
|
| 582 |
+
throw new Error(`ComfyUI generation did not succeed.\n\n${errorMessages}`.trim());
|
| 583 |
+
}
|
| 584 |
+
const outputs = Object.keys(item.outputs).map(it => item.outputs[it]);
|
| 585 |
+
console.debug('ComfyUI outputs:', outputs);
|
| 586 |
+
const imgInfo = outputs.map(it => it.images).flat()[0] ?? outputs.map(it => it.gifs).flat()[0];
|
| 587 |
+
if (!imgInfo) {
|
| 588 |
+
throw new Error('ComfyUI did not return any recognizable outputs.');
|
| 589 |
+
}
|
| 590 |
+
const imgUrl = new URL(urlJoin(request.body.url, '/view'));
|
| 591 |
+
imgUrl.search = `?filename=${imgInfo.filename}&subfolder=${imgInfo.subfolder}&type=${imgInfo.type}`;
|
| 592 |
+
const imgResponse = await fetch(imgUrl);
|
| 593 |
+
if (!imgResponse.ok) {
|
| 594 |
+
throw new Error('ComfyUI returned an error.');
|
| 595 |
+
}
|
| 596 |
+
const format = path.extname(imgInfo.filename).slice(1).toLowerCase() || 'png';
|
| 597 |
+
const imgBuffer = await imgResponse.arrayBuffer();
|
| 598 |
+
return response.send({ format: format, data: Buffer.from(imgBuffer).toString('base64') });
|
| 599 |
+
} catch (error) {
|
| 600 |
+
console.error('ComfyUI error:', error);
|
| 601 |
+
response.status(500).send(error.message);
|
| 602 |
+
return response;
|
| 603 |
+
}
|
| 604 |
+
});
|
| 605 |
+
|
| 606 |
+
const comfyRunPod = express.Router();
|
| 607 |
+
|
| 608 |
+
comfyRunPod.post('/ping', async (request, response) => {
|
| 609 |
+
try {
|
| 610 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.COMFY_RUNPOD);
|
| 611 |
+
|
| 612 |
+
if (!key) {
|
| 613 |
+
console.warn('RunPod key not found.');
|
| 614 |
+
return response.sendStatus(400);
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
const url = new URL(urlJoin(request.body.url, '/health'));
|
| 618 |
+
|
| 619 |
+
const result = await fetch(url, {
|
| 620 |
+
method: 'GET',
|
| 621 |
+
headers: { 'Authorization': `Bearer ${key}` },
|
| 622 |
+
});
|
| 623 |
+
if (!result.ok) {
|
| 624 |
+
throw new Error('ComfyUI returned an error.');
|
| 625 |
+
}
|
| 626 |
+
/** @type {any} */
|
| 627 |
+
const data = await result.json();
|
| 628 |
+
if (data.workers.ready <= 0) {
|
| 629 |
+
console.warn(`No workers reported as ready. ${result}`);
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
return response.sendStatus(200);
|
| 633 |
+
} catch (error) {
|
| 634 |
+
console.error(error);
|
| 635 |
+
return response.sendStatus(500);
|
| 636 |
+
}
|
| 637 |
+
});
|
| 638 |
+
|
| 639 |
+
comfyRunPod.post('/generate', async (request, response) => {
|
| 640 |
+
try {
|
| 641 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.COMFY_RUNPOD);
|
| 642 |
+
|
| 643 |
+
if (!key) {
|
| 644 |
+
console.warn('RunPod key not found.');
|
| 645 |
+
return response.sendStatus(400);
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
let jobId;
|
| 649 |
+
let item;
|
| 650 |
+
const url = new URL(urlJoin(request.body.url, '/run'));
|
| 651 |
+
|
| 652 |
+
const controller = new AbortController();
|
| 653 |
+
request.socket.removeAllListeners('close');
|
| 654 |
+
request.socket.on('close', function () {
|
| 655 |
+
if (!response.writableEnded && !item) {
|
| 656 |
+
const interruptUrl = new URL(urlJoin(request.body.url, `/cancel/${jobId}`));
|
| 657 |
+
fetch(interruptUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${key}` } });
|
| 658 |
+
}
|
| 659 |
+
controller.abort();
|
| 660 |
+
});
|
| 661 |
+
const workflow = JSON.parse(request.body.prompt).prompt;
|
| 662 |
+
const wrappedWorkflow = workflow?.input?.workflow ? workflow : ({ input: { workflow: workflow } });
|
| 663 |
+
const runpodPrompt = JSON.stringify(wrappedWorkflow);
|
| 664 |
+
|
| 665 |
+
console.debug('ComfyUI RunPod request:', wrappedWorkflow);
|
| 666 |
+
|
| 667 |
+
const promptResult = await fetch(url, {
|
| 668 |
+
method: 'POST',
|
| 669 |
+
headers: { 'Authorization': `Bearer ${key}` },
|
| 670 |
+
body: runpodPrompt,
|
| 671 |
+
});
|
| 672 |
+
if (!promptResult.ok) {
|
| 673 |
+
const text = await promptResult.text();
|
| 674 |
+
throw new Error('ComfyUI returned an error.', { cause: tryParse(text) });
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
/** @type {any} */
|
| 678 |
+
const data = await promptResult.json();
|
| 679 |
+
jobId = data.id;
|
| 680 |
+
const statusUrl = new URL(urlJoin(request.body.url, `/status/${jobId}`));
|
| 681 |
+
while (true) {
|
| 682 |
+
const result = await fetch(statusUrl, {
|
| 683 |
+
method: 'GET',
|
| 684 |
+
headers: { 'Authorization': `Bearer ${key}` },
|
| 685 |
+
});
|
| 686 |
+
if (!result.ok) {
|
| 687 |
+
throw new Error('ComfyUI returned an error.');
|
| 688 |
+
}
|
| 689 |
+
/** @type {any} */
|
| 690 |
+
const status = await result.json();
|
| 691 |
+
if (status.output) {
|
| 692 |
+
item = status.output.images[0];
|
| 693 |
+
}
|
| 694 |
+
if (item) {
|
| 695 |
+
break;
|
| 696 |
+
}
|
| 697 |
+
await delay(500);
|
| 698 |
+
}
|
| 699 |
+
const format = path.extname(item.filename).slice(1).toLowerCase() || 'png';
|
| 700 |
+
return response.send({ format: format, data: item.data });
|
| 701 |
+
} catch (error) {
|
| 702 |
+
console.error('ComfyUI error:', error);
|
| 703 |
+
response.status(500).send(error.message);
|
| 704 |
+
return response;
|
| 705 |
+
}
|
| 706 |
+
});
|
| 707 |
+
|
| 708 |
+
const together = express.Router();
|
| 709 |
+
|
| 710 |
+
together.post('/models', async (request, response) => {
|
| 711 |
+
try {
|
| 712 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.TOGETHERAI);
|
| 713 |
+
|
| 714 |
+
if (!key) {
|
| 715 |
+
console.warn('TogetherAI key not found.');
|
| 716 |
+
return response.sendStatus(400);
|
| 717 |
+
}
|
| 718 |
+
|
| 719 |
+
const modelsResponse = await fetch('https://api.together.xyz/api/models', {
|
| 720 |
+
method: 'GET',
|
| 721 |
+
headers: {
|
| 722 |
+
'Authorization': `Bearer ${key}`,
|
| 723 |
+
},
|
| 724 |
+
});
|
| 725 |
+
|
| 726 |
+
if (!modelsResponse.ok) {
|
| 727 |
+
console.warn('TogetherAI returned an error.');
|
| 728 |
+
return response.sendStatus(500);
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
const data = await modelsResponse.json();
|
| 732 |
+
|
| 733 |
+
if (!Array.isArray(data)) {
|
| 734 |
+
console.warn('TogetherAI returned invalid data.');
|
| 735 |
+
return response.sendStatus(500);
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
const models = data
|
| 739 |
+
.filter(x => x.type === 'image')
|
| 740 |
+
.map(x => ({ value: x.id, text: x.display_name }));
|
| 741 |
+
|
| 742 |
+
return response.send(models);
|
| 743 |
+
} catch (error) {
|
| 744 |
+
console.error(error);
|
| 745 |
+
return response.sendStatus(500);
|
| 746 |
+
}
|
| 747 |
+
});
|
| 748 |
+
|
| 749 |
+
together.post('/generate', async (request, response) => {
|
| 750 |
+
try {
|
| 751 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.TOGETHERAI);
|
| 752 |
+
|
| 753 |
+
if (!key) {
|
| 754 |
+
console.warn('TogetherAI key not found.');
|
| 755 |
+
return response.sendStatus(400);
|
| 756 |
+
}
|
| 757 |
+
|
| 758 |
+
console.debug('TogetherAI request:', request.body);
|
| 759 |
+
|
| 760 |
+
const result = await fetch('https://api.together.xyz/v1/images/generations', {
|
| 761 |
+
method: 'POST',
|
| 762 |
+
body: JSON.stringify({
|
| 763 |
+
prompt: request.body.prompt,
|
| 764 |
+
negative_prompt: request.body.negative_prompt,
|
| 765 |
+
height: request.body.height,
|
| 766 |
+
width: request.body.width,
|
| 767 |
+
model: request.body.model,
|
| 768 |
+
steps: request.body.steps,
|
| 769 |
+
n: 1,
|
| 770 |
+
// Limited to 10000 on playground, works fine with more.
|
| 771 |
+
seed: request.body.seed >= 0 ? request.body.seed : Math.floor(Math.random() * 10_000_000),
|
| 772 |
+
}),
|
| 773 |
+
headers: {
|
| 774 |
+
'Content-Type': 'application/json',
|
| 775 |
+
'Authorization': `Bearer ${key}`,
|
| 776 |
+
},
|
| 777 |
+
});
|
| 778 |
+
|
| 779 |
+
if (!result.ok) {
|
| 780 |
+
console.warn('TogetherAI returned an error.', { body: await result.text() });
|
| 781 |
+
return response.sendStatus(500);
|
| 782 |
+
}
|
| 783 |
+
|
| 784 |
+
/** @type {any} */
|
| 785 |
+
const data = await result.json();
|
| 786 |
+
console.debug('TogetherAI response:', data);
|
| 787 |
+
|
| 788 |
+
const choice = data?.data?.[0];
|
| 789 |
+
let b64_json = choice.b64_json;
|
| 790 |
+
|
| 791 |
+
if (!b64_json) {
|
| 792 |
+
const buffer = await (await fetch(choice.url)).arrayBuffer();
|
| 793 |
+
b64_json = Buffer.from(buffer).toString('base64');
|
| 794 |
+
}
|
| 795 |
+
|
| 796 |
+
return response.send({ format: 'jpg', data: b64_json });
|
| 797 |
+
} catch (error) {
|
| 798 |
+
console.error(error);
|
| 799 |
+
return response.sendStatus(500);
|
| 800 |
+
}
|
| 801 |
+
});
|
| 802 |
+
|
| 803 |
+
const drawthings = express.Router();
|
| 804 |
+
|
| 805 |
+
drawthings.post('/ping', async (request, response) => {
|
| 806 |
+
try {
|
| 807 |
+
const url = new URL(request.body.url);
|
| 808 |
+
url.pathname = '/';
|
| 809 |
+
|
| 810 |
+
const result = await fetch(url, {
|
| 811 |
+
method: 'HEAD',
|
| 812 |
+
});
|
| 813 |
+
|
| 814 |
+
if (!result.ok) {
|
| 815 |
+
throw new Error('SD DrawThings API returned an error.');
|
| 816 |
+
}
|
| 817 |
+
|
| 818 |
+
return response.sendStatus(200);
|
| 819 |
+
} catch (error) {
|
| 820 |
+
console.error(error);
|
| 821 |
+
return response.sendStatus(500);
|
| 822 |
+
}
|
| 823 |
+
});
|
| 824 |
+
|
| 825 |
+
drawthings.post('/get-model', async (request, response) => {
|
| 826 |
+
try {
|
| 827 |
+
const url = new URL(request.body.url);
|
| 828 |
+
url.pathname = '/';
|
| 829 |
+
|
| 830 |
+
const result = await fetch(url, {
|
| 831 |
+
method: 'GET',
|
| 832 |
+
});
|
| 833 |
+
|
| 834 |
+
/** @type {any} */
|
| 835 |
+
const data = await result.json();
|
| 836 |
+
|
| 837 |
+
return response.send(data['model']);
|
| 838 |
+
} catch (error) {
|
| 839 |
+
console.error(error);
|
| 840 |
+
return response.sendStatus(500);
|
| 841 |
+
}
|
| 842 |
+
});
|
| 843 |
+
|
| 844 |
+
drawthings.post('/get-upscaler', async (request, response) => {
|
| 845 |
+
try {
|
| 846 |
+
const url = new URL(request.body.url);
|
| 847 |
+
url.pathname = '/';
|
| 848 |
+
|
| 849 |
+
const result = await fetch(url, {
|
| 850 |
+
method: 'GET',
|
| 851 |
+
});
|
| 852 |
+
|
| 853 |
+
/** @type {any} */
|
| 854 |
+
const data = await result.json();
|
| 855 |
+
|
| 856 |
+
return response.send(data['upscaler']);
|
| 857 |
+
} catch (error) {
|
| 858 |
+
console.error(error);
|
| 859 |
+
return response.sendStatus(500);
|
| 860 |
+
}
|
| 861 |
+
});
|
| 862 |
+
|
| 863 |
+
drawthings.post('/generate', async (request, response) => {
|
| 864 |
+
try {
|
| 865 |
+
console.debug('SD DrawThings API request:', request.body);
|
| 866 |
+
|
| 867 |
+
const url = new URL(request.body.url);
|
| 868 |
+
url.pathname = '/sdapi/v1/txt2img';
|
| 869 |
+
|
| 870 |
+
const body = { ...request.body };
|
| 871 |
+
const auth = getBasicAuthHeader(request.body.auth);
|
| 872 |
+
delete body.url;
|
| 873 |
+
delete body.auth;
|
| 874 |
+
|
| 875 |
+
const result = await fetch(url, {
|
| 876 |
+
method: 'POST',
|
| 877 |
+
body: JSON.stringify(body),
|
| 878 |
+
headers: {
|
| 879 |
+
'Content-Type': 'application/json',
|
| 880 |
+
'Authorization': auth,
|
| 881 |
+
},
|
| 882 |
+
});
|
| 883 |
+
|
| 884 |
+
if (!result.ok) {
|
| 885 |
+
const text = await result.text();
|
| 886 |
+
throw new Error('SD DrawThings API returned an error.', { cause: text });
|
| 887 |
+
}
|
| 888 |
+
|
| 889 |
+
const data = await result.json();
|
| 890 |
+
return response.send(data);
|
| 891 |
+
} catch (error) {
|
| 892 |
+
console.error(error);
|
| 893 |
+
return response.sendStatus(500);
|
| 894 |
+
}
|
| 895 |
+
});
|
| 896 |
+
|
| 897 |
+
const pollinations = express.Router();
|
| 898 |
+
|
| 899 |
+
pollinations.post('/models', async (_request, response) => {
|
| 900 |
+
try {
|
| 901 |
+
const modelsUrl = new URL('https://image.pollinations.ai/models');
|
| 902 |
+
const result = await fetch(modelsUrl);
|
| 903 |
+
|
| 904 |
+
if (!result.ok) {
|
| 905 |
+
console.warn('Pollinations returned an error.', result.status, result.statusText);
|
| 906 |
+
throw new Error('Pollinations request failed.');
|
| 907 |
+
}
|
| 908 |
+
|
| 909 |
+
const data = await result.json();
|
| 910 |
+
|
| 911 |
+
if (!Array.isArray(data)) {
|
| 912 |
+
console.warn('Pollinations returned invalid data.');
|
| 913 |
+
throw new Error('Pollinations request failed.');
|
| 914 |
+
}
|
| 915 |
+
|
| 916 |
+
const models = data.map(x => ({ value: x, text: x }));
|
| 917 |
+
return response.send(models);
|
| 918 |
+
} catch (error) {
|
| 919 |
+
console.error(error);
|
| 920 |
+
return response.sendStatus(500);
|
| 921 |
+
}
|
| 922 |
+
});
|
| 923 |
+
|
| 924 |
+
pollinations.post('/generate', async (request, response) => {
|
| 925 |
+
try {
|
| 926 |
+
const promptUrl = new URL(`https://image.pollinations.ai/prompt/${encodeURIComponent(request.body.prompt)}`);
|
| 927 |
+
const params = new URLSearchParams({
|
| 928 |
+
model: String(request.body.model),
|
| 929 |
+
negative_prompt: String(request.body.negative_prompt),
|
| 930 |
+
seed: String(request.body.seed >= 0 ? request.body.seed : Math.floor(Math.random() * 10_000_000)),
|
| 931 |
+
width: String(request.body.width ?? 1024),
|
| 932 |
+
height: String(request.body.height ?? 1024),
|
| 933 |
+
nologo: String(true),
|
| 934 |
+
nofeed: String(true),
|
| 935 |
+
private: String(true),
|
| 936 |
+
referrer: 'tavernintern',
|
| 937 |
+
});
|
| 938 |
+
if (request.body.enhance) {
|
| 939 |
+
params.set('enhance', String(true));
|
| 940 |
+
}
|
| 941 |
+
promptUrl.search = params.toString();
|
| 942 |
+
|
| 943 |
+
console.info('Pollinations request URL:', promptUrl.toString());
|
| 944 |
+
|
| 945 |
+
const result = await fetch(promptUrl);
|
| 946 |
+
|
| 947 |
+
if (!result.ok) {
|
| 948 |
+
const text = await result.text();
|
| 949 |
+
console.warn('Pollinations returned an error.', text);
|
| 950 |
+
throw new Error('Pollinations request failed.');
|
| 951 |
+
}
|
| 952 |
+
|
| 953 |
+
const buffer = await result.arrayBuffer();
|
| 954 |
+
const base64 = Buffer.from(buffer).toString('base64');
|
| 955 |
+
|
| 956 |
+
return response.send({ image: base64 });
|
| 957 |
+
} catch (error) {
|
| 958 |
+
console.error(error);
|
| 959 |
+
return response.sendStatus(500);
|
| 960 |
+
}
|
| 961 |
+
});
|
| 962 |
+
|
| 963 |
+
const stability = express.Router();
|
| 964 |
+
|
| 965 |
+
stability.post('/generate', async (request, response) => {
|
| 966 |
+
try {
|
| 967 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.STABILITY);
|
| 968 |
+
|
| 969 |
+
if (!key) {
|
| 970 |
+
console.warn('Stability AI key not found.');
|
| 971 |
+
return response.sendStatus(400);
|
| 972 |
+
}
|
| 973 |
+
|
| 974 |
+
const { payload, model } = request.body;
|
| 975 |
+
|
| 976 |
+
console.debug('Stability AI request:', model, payload);
|
| 977 |
+
|
| 978 |
+
const formData = new FormData();
|
| 979 |
+
for (const [key, value] of Object.entries(payload)) {
|
| 980 |
+
if (value !== undefined) {
|
| 981 |
+
formData.append(key, String(value));
|
| 982 |
+
}
|
| 983 |
+
}
|
| 984 |
+
|
| 985 |
+
let apiUrl;
|
| 986 |
+
switch (model) {
|
| 987 |
+
case 'stable-image-ultra':
|
| 988 |
+
apiUrl = 'https://api.stability.ai/v2beta/stable-image/generate/ultra';
|
| 989 |
+
break;
|
| 990 |
+
case 'stable-image-core':
|
| 991 |
+
apiUrl = 'https://api.stability.ai/v2beta/stable-image/generate/core';
|
| 992 |
+
break;
|
| 993 |
+
case 'stable-diffusion-3':
|
| 994 |
+
apiUrl = 'https://api.stability.ai/v2beta/stable-image/generate/sd3';
|
| 995 |
+
break;
|
| 996 |
+
default:
|
| 997 |
+
throw new Error('Invalid Stability AI model selected');
|
| 998 |
+
}
|
| 999 |
+
|
| 1000 |
+
const result = await fetch(apiUrl, {
|
| 1001 |
+
method: 'POST',
|
| 1002 |
+
headers: {
|
| 1003 |
+
'Authorization': `Bearer ${key}`,
|
| 1004 |
+
'Accept': 'image/*',
|
| 1005 |
+
},
|
| 1006 |
+
body: formData,
|
| 1007 |
+
});
|
| 1008 |
+
|
| 1009 |
+
if (!result.ok) {
|
| 1010 |
+
const text = await result.text();
|
| 1011 |
+
console.warn('Stability AI returned an error.', result.status, result.statusText, text);
|
| 1012 |
+
return response.sendStatus(500);
|
| 1013 |
+
}
|
| 1014 |
+
|
| 1015 |
+
const buffer = await result.arrayBuffer();
|
| 1016 |
+
return response.send(Buffer.from(buffer).toString('base64'));
|
| 1017 |
+
} catch (error) {
|
| 1018 |
+
console.error(error);
|
| 1019 |
+
return response.sendStatus(500);
|
| 1020 |
+
}
|
| 1021 |
+
});
|
| 1022 |
+
|
| 1023 |
+
const huggingface = express.Router();
|
| 1024 |
+
|
| 1025 |
+
huggingface.post('/generate', async (request, response) => {
|
| 1026 |
+
try {
|
| 1027 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.HUGGINGFACE);
|
| 1028 |
+
|
| 1029 |
+
if (!key) {
|
| 1030 |
+
console.warn('Hugging Face key not found.');
|
| 1031 |
+
return response.sendStatus(400);
|
| 1032 |
+
}
|
| 1033 |
+
|
| 1034 |
+
console.debug('Hugging Face request:', request.body);
|
| 1035 |
+
|
| 1036 |
+
const result = await fetch(`https://api-inference.huggingface.co/models/${request.body.model}`, {
|
| 1037 |
+
method: 'POST',
|
| 1038 |
+
body: JSON.stringify({
|
| 1039 |
+
inputs: request.body.prompt,
|
| 1040 |
+
}),
|
| 1041 |
+
headers: {
|
| 1042 |
+
'Content-Type': 'application/json',
|
| 1043 |
+
'Authorization': `Bearer ${key}`,
|
| 1044 |
+
},
|
| 1045 |
+
});
|
| 1046 |
+
|
| 1047 |
+
if (!result.ok) {
|
| 1048 |
+
console.warn('Hugging Face returned an error.');
|
| 1049 |
+
return response.sendStatus(500);
|
| 1050 |
+
}
|
| 1051 |
+
|
| 1052 |
+
const buffer = await result.arrayBuffer();
|
| 1053 |
+
return response.send({
|
| 1054 |
+
image: Buffer.from(buffer).toString('base64'),
|
| 1055 |
+
});
|
| 1056 |
+
} catch (error) {
|
| 1057 |
+
console.error(error);
|
| 1058 |
+
return response.sendStatus(500);
|
| 1059 |
+
}
|
| 1060 |
+
});
|
| 1061 |
+
|
| 1062 |
+
const electronhub = express.Router();
|
| 1063 |
+
|
| 1064 |
+
electronhub.post('/models', async (request, response) => {
|
| 1065 |
+
try {
|
| 1066 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.ELECTRONHUB);
|
| 1067 |
+
|
| 1068 |
+
if (!key) {
|
| 1069 |
+
console.warn('Electron Hub key not found.');
|
| 1070 |
+
return response.sendStatus(400);
|
| 1071 |
+
}
|
| 1072 |
+
|
| 1073 |
+
const modelsResponse = await fetch('https://api.electronhub.ai/v1/models', {
|
| 1074 |
+
method: 'GET',
|
| 1075 |
+
headers: {
|
| 1076 |
+
'Authorization': `Bearer ${key}`,
|
| 1077 |
+
'Content-Type': 'application/json',
|
| 1078 |
+
},
|
| 1079 |
+
});
|
| 1080 |
+
|
| 1081 |
+
if (!modelsResponse.ok) {
|
| 1082 |
+
console.warn('Electron Hub returned an error.');
|
| 1083 |
+
return response.sendStatus(500);
|
| 1084 |
+
}
|
| 1085 |
+
|
| 1086 |
+
/** @type {any} */
|
| 1087 |
+
const data = await modelsResponse.json();
|
| 1088 |
+
|
| 1089 |
+
if (!Array.isArray(data?.data)) {
|
| 1090 |
+
console.warn('Electron Hub returned invalid data.');
|
| 1091 |
+
return response.sendStatus(500);
|
| 1092 |
+
}
|
| 1093 |
+
|
| 1094 |
+
const models = data.data
|
| 1095 |
+
.filter(x => x && Array.isArray(x.endpoints) && x.endpoints.includes('/v1/images/generations'))
|
| 1096 |
+
.map(x => ({ ...x, value: x.id, text: x.name }));
|
| 1097 |
+
return response.send(models);
|
| 1098 |
+
} catch (error) {
|
| 1099 |
+
console.error(error);
|
| 1100 |
+
return response.sendStatus(500);
|
| 1101 |
+
}
|
| 1102 |
+
});
|
| 1103 |
+
|
| 1104 |
+
electronhub.post('/generate', async (request, response) => {
|
| 1105 |
+
try {
|
| 1106 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.ELECTRONHUB);
|
| 1107 |
+
|
| 1108 |
+
if (!key) {
|
| 1109 |
+
console.warn('Electron Hub key not found.');
|
| 1110 |
+
return response.sendStatus(400);
|
| 1111 |
+
}
|
| 1112 |
+
|
| 1113 |
+
let bodyParams = {
|
| 1114 |
+
model: request.body.model,
|
| 1115 |
+
prompt: request.body.prompt,
|
| 1116 |
+
response_format: 'b64_json',
|
| 1117 |
+
};
|
| 1118 |
+
|
| 1119 |
+
if (request.body.size) {
|
| 1120 |
+
bodyParams.size = request.body.size;
|
| 1121 |
+
}
|
| 1122 |
+
|
| 1123 |
+
if (request.body.quality) {
|
| 1124 |
+
bodyParams.quality = request.body.quality;
|
| 1125 |
+
}
|
| 1126 |
+
|
| 1127 |
+
console.debug('Electron Hub request:', bodyParams);
|
| 1128 |
+
|
| 1129 |
+
const result = await fetch('https://api.electronhub.ai/v1/images/generations', {
|
| 1130 |
+
method: 'POST',
|
| 1131 |
+
headers: {
|
| 1132 |
+
'Authorization': `Bearer ${key}`,
|
| 1133 |
+
'Content-Type': 'application/json',
|
| 1134 |
+
},
|
| 1135 |
+
body: JSON.stringify({
|
| 1136 |
+
...bodyParams,
|
| 1137 |
+
}),
|
| 1138 |
+
});
|
| 1139 |
+
|
| 1140 |
+
if (!result.ok) {
|
| 1141 |
+
const errorText = await result.text();
|
| 1142 |
+
console.warn('Electron Hub returned an error.', result.status, result.statusText, errorText);
|
| 1143 |
+
return response.sendStatus(500);
|
| 1144 |
+
}
|
| 1145 |
+
|
| 1146 |
+
/** @type {any} */
|
| 1147 |
+
const data = await result.json();
|
| 1148 |
+
const image = data?.data?.[0]?.b64_json;
|
| 1149 |
+
|
| 1150 |
+
if (!image) {
|
| 1151 |
+
console.warn('Electron Hub returned invalid data.');
|
| 1152 |
+
return response.sendStatus(500);
|
| 1153 |
+
}
|
| 1154 |
+
|
| 1155 |
+
return response.send({ image });
|
| 1156 |
+
} catch (error) {
|
| 1157 |
+
console.error(error);
|
| 1158 |
+
return response.sendStatus(500);
|
| 1159 |
+
}
|
| 1160 |
+
});
|
| 1161 |
+
|
| 1162 |
+
electronhub.post('/sizes', async (request, response) => {
|
| 1163 |
+
const result = await fetch(`https://api.electronhub.ai/v1/models/${request.body.model}`, {
|
| 1164 |
+
method: 'GET',
|
| 1165 |
+
headers: {
|
| 1166 |
+
'Content-Type': 'application/json',
|
| 1167 |
+
},
|
| 1168 |
+
});
|
| 1169 |
+
|
| 1170 |
+
if (!result.ok) {
|
| 1171 |
+
console.warn('Electron Hub returned an error.');
|
| 1172 |
+
return response.sendStatus(500);
|
| 1173 |
+
}
|
| 1174 |
+
|
| 1175 |
+
/** @type {any} */
|
| 1176 |
+
const data = await result.json();
|
| 1177 |
+
|
| 1178 |
+
const sizes = data.sizes;
|
| 1179 |
+
|
| 1180 |
+
if (!sizes) {
|
| 1181 |
+
console.warn('Electron Hub returned invalid data.');
|
| 1182 |
+
return response.sendStatus(500);
|
| 1183 |
+
}
|
| 1184 |
+
|
| 1185 |
+
return response.send({ sizes });
|
| 1186 |
+
});
|
| 1187 |
+
|
| 1188 |
+
const chutes = express.Router();
|
| 1189 |
+
|
| 1190 |
+
chutes.post('/models', async (request, response) => {
|
| 1191 |
+
try {
|
| 1192 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.CHUTES);
|
| 1193 |
+
|
| 1194 |
+
if (!key) {
|
| 1195 |
+
console.warn('Chutes key not found.');
|
| 1196 |
+
return response.sendStatus(400);
|
| 1197 |
+
}
|
| 1198 |
+
|
| 1199 |
+
const modelsResponse = await fetch('https://api.chutes.ai/chutes/?template=diffusion&include_public=true&limit=999', {
|
| 1200 |
+
method: 'GET',
|
| 1201 |
+
headers: {
|
| 1202 |
+
'Authorization': `Bearer ${key}`,
|
| 1203 |
+
'Content-Type': 'application/json',
|
| 1204 |
+
},
|
| 1205 |
+
});
|
| 1206 |
+
|
| 1207 |
+
if (!modelsResponse.ok) {
|
| 1208 |
+
console.warn('Chutes returned an error.');
|
| 1209 |
+
return response.sendStatus(500);
|
| 1210 |
+
}
|
| 1211 |
+
|
| 1212 |
+
const data = await modelsResponse.json();
|
| 1213 |
+
|
| 1214 |
+
const chutesData = /** @type {{items: Array<{name: string}>}} */ (data);
|
| 1215 |
+
const models = chutesData.items.map(x => ({ value: x.name, text: x.name })).sort((a, b) => a?.text?.localeCompare(b?.text));
|
| 1216 |
+
return response.send(models);
|
| 1217 |
+
}
|
| 1218 |
+
catch (error) {
|
| 1219 |
+
console.error(error);
|
| 1220 |
+
return response.sendStatus(500);
|
| 1221 |
+
}
|
| 1222 |
+
});
|
| 1223 |
+
|
| 1224 |
+
chutes.post('/generate', async (request, response) => {
|
| 1225 |
+
try {
|
| 1226 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.CHUTES);
|
| 1227 |
+
|
| 1228 |
+
if (!key) {
|
| 1229 |
+
console.warn('Chutes key not found.');
|
| 1230 |
+
return response.sendStatus(400);
|
| 1231 |
+
}
|
| 1232 |
+
|
| 1233 |
+
const bodyParams = {
|
| 1234 |
+
model: request.body.model,
|
| 1235 |
+
prompt: request.body.prompt,
|
| 1236 |
+
negative_prompt: request.body.negative_prompt,
|
| 1237 |
+
guidance_scale: request.body.guidance_scale || 7.0,
|
| 1238 |
+
width: request.body.width || 1024,
|
| 1239 |
+
height: request.body.height || 1024,
|
| 1240 |
+
num_inference_steps: request.body.steps || 10,
|
| 1241 |
+
};
|
| 1242 |
+
|
| 1243 |
+
console.debug('Chutes request:', bodyParams);
|
| 1244 |
+
|
| 1245 |
+
const result = await fetch('https://image.chutes.ai/generate', {
|
| 1246 |
+
method: 'POST',
|
| 1247 |
+
headers: {
|
| 1248 |
+
'Authorization': `Bearer ${key}`,
|
| 1249 |
+
'Content-Type': 'application/json',
|
| 1250 |
+
},
|
| 1251 |
+
body: JSON.stringify(bodyParams),
|
| 1252 |
+
});
|
| 1253 |
+
|
| 1254 |
+
if (!result.ok) {
|
| 1255 |
+
const text = await result.text();
|
| 1256 |
+
console.warn('Chutes returned an error:', text);
|
| 1257 |
+
return response.sendStatus(500);
|
| 1258 |
+
}
|
| 1259 |
+
|
| 1260 |
+
const buffer = await result.arrayBuffer();
|
| 1261 |
+
const base64 = Buffer.from(buffer).toString('base64');
|
| 1262 |
+
|
| 1263 |
+
return response.send({ image: base64 });
|
| 1264 |
+
}
|
| 1265 |
+
catch (error) {
|
| 1266 |
+
console.error(error);
|
| 1267 |
+
return response.sendStatus(500);
|
| 1268 |
+
}
|
| 1269 |
+
});
|
| 1270 |
+
|
| 1271 |
+
const nanogpt = express.Router();
|
| 1272 |
+
|
| 1273 |
+
nanogpt.post('/models', async (request, response) => {
|
| 1274 |
+
try {
|
| 1275 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.NANOGPT);
|
| 1276 |
+
|
| 1277 |
+
if (!key) {
|
| 1278 |
+
console.warn('NanoGPT key not found.');
|
| 1279 |
+
return response.sendStatus(400);
|
| 1280 |
+
}
|
| 1281 |
+
|
| 1282 |
+
const modelsResponse = await fetch('https://nano-gpt.com/api/models', {
|
| 1283 |
+
method: 'GET',
|
| 1284 |
+
headers: {
|
| 1285 |
+
'x-api-key': key,
|
| 1286 |
+
'Content-Type': 'application/json',
|
| 1287 |
+
},
|
| 1288 |
+
});
|
| 1289 |
+
|
| 1290 |
+
if (!modelsResponse.ok) {
|
| 1291 |
+
console.warn('NanoGPT returned an error.');
|
| 1292 |
+
return response.sendStatus(500);
|
| 1293 |
+
}
|
| 1294 |
+
|
| 1295 |
+
/** @type {any} */
|
| 1296 |
+
const data = await modelsResponse.json();
|
| 1297 |
+
const imageModels = data?.models?.image;
|
| 1298 |
+
|
| 1299 |
+
if (!imageModels || typeof imageModels !== 'object') {
|
| 1300 |
+
console.warn('NanoGPT returned invalid data.');
|
| 1301 |
+
return response.sendStatus(500);
|
| 1302 |
+
}
|
| 1303 |
+
|
| 1304 |
+
const models = Object.values(imageModels).map(x => ({ value: x.model, text: x.name }));
|
| 1305 |
+
return response.send(models);
|
| 1306 |
+
}
|
| 1307 |
+
catch (error) {
|
| 1308 |
+
console.error(error);
|
| 1309 |
+
return response.sendStatus(500);
|
| 1310 |
+
}
|
| 1311 |
+
});
|
| 1312 |
+
|
| 1313 |
+
nanogpt.post('/generate', async (request, response) => {
|
| 1314 |
+
try {
|
| 1315 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.NANOGPT);
|
| 1316 |
+
|
| 1317 |
+
if (!key) {
|
| 1318 |
+
console.warn('NanoGPT key not found.');
|
| 1319 |
+
return response.sendStatus(400);
|
| 1320 |
+
}
|
| 1321 |
+
|
| 1322 |
+
console.debug('NanoGPT request:', request.body);
|
| 1323 |
+
|
| 1324 |
+
const result = await fetch('https://nano-gpt.com/api/generate-image', {
|
| 1325 |
+
method: 'POST',
|
| 1326 |
+
body: JSON.stringify(request.body),
|
| 1327 |
+
headers: {
|
| 1328 |
+
'x-api-key': key,
|
| 1329 |
+
'Content-Type': 'application/json',
|
| 1330 |
+
},
|
| 1331 |
+
});
|
| 1332 |
+
|
| 1333 |
+
if (!result.ok) {
|
| 1334 |
+
console.warn('NanoGPT returned an error.');
|
| 1335 |
+
return response.sendStatus(500);
|
| 1336 |
+
}
|
| 1337 |
+
|
| 1338 |
+
/** @type {any} */
|
| 1339 |
+
const data = await result.json();
|
| 1340 |
+
|
| 1341 |
+
const image = data?.data?.[0]?.b64_json;
|
| 1342 |
+
if (!image) {
|
| 1343 |
+
console.warn('NanoGPT returned invalid data.');
|
| 1344 |
+
return response.sendStatus(500);
|
| 1345 |
+
}
|
| 1346 |
+
|
| 1347 |
+
return response.send({ image });
|
| 1348 |
+
}
|
| 1349 |
+
catch (error) {
|
| 1350 |
+
console.error(error);
|
| 1351 |
+
return response.sendStatus(500);
|
| 1352 |
+
}
|
| 1353 |
+
});
|
| 1354 |
+
|
| 1355 |
+
const bfl = express.Router();
|
| 1356 |
+
|
| 1357 |
+
bfl.post('/generate', async (request, response) => {
|
| 1358 |
+
try {
|
| 1359 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.BFL);
|
| 1360 |
+
|
| 1361 |
+
if (!key) {
|
| 1362 |
+
console.warn('BFL key not found.');
|
| 1363 |
+
return response.sendStatus(400);
|
| 1364 |
+
}
|
| 1365 |
+
|
| 1366 |
+
const requestBody = {
|
| 1367 |
+
prompt: request.body.prompt,
|
| 1368 |
+
steps: request.body.steps,
|
| 1369 |
+
guidance: request.body.guidance,
|
| 1370 |
+
width: request.body.width,
|
| 1371 |
+
height: request.body.height,
|
| 1372 |
+
prompt_upsampling: request.body.prompt_upsampling,
|
| 1373 |
+
seed: request.body.seed ?? null,
|
| 1374 |
+
safety_tolerance: 6, // being least strict
|
| 1375 |
+
output_format: 'jpeg',
|
| 1376 |
+
};
|
| 1377 |
+
|
| 1378 |
+
function getClosestAspectRatio(width, height) {
|
| 1379 |
+
const minAspect = 9 / 21;
|
| 1380 |
+
const maxAspect = 21 / 9;
|
| 1381 |
+
const currentAspect = width / height;
|
| 1382 |
+
|
| 1383 |
+
const gcd = (a, b) => b === 0 ? a : gcd(b, a % b);
|
| 1384 |
+
const simplifyRatio = (w, h) => {
|
| 1385 |
+
const divisor = gcd(w, h);
|
| 1386 |
+
return `${w / divisor}:${h / divisor}`;
|
| 1387 |
+
};
|
| 1388 |
+
|
| 1389 |
+
if (currentAspect < minAspect) {
|
| 1390 |
+
const adjustedHeight = Math.round(width / minAspect);
|
| 1391 |
+
return simplifyRatio(width, adjustedHeight);
|
| 1392 |
+
} else if (currentAspect > maxAspect) {
|
| 1393 |
+
const adjustedWidth = Math.round(height * maxAspect);
|
| 1394 |
+
return simplifyRatio(adjustedWidth, height);
|
| 1395 |
+
} else {
|
| 1396 |
+
return simplifyRatio(width, height);
|
| 1397 |
+
}
|
| 1398 |
+
}
|
| 1399 |
+
|
| 1400 |
+
if (String(request.body.model).endsWith('-ultra')) {
|
| 1401 |
+
requestBody.aspect_ratio = getClosestAspectRatio(request.body.width, request.body.height);
|
| 1402 |
+
delete requestBody.steps;
|
| 1403 |
+
delete requestBody.guidance;
|
| 1404 |
+
delete requestBody.width;
|
| 1405 |
+
delete requestBody.height;
|
| 1406 |
+
delete requestBody.prompt_upsampling;
|
| 1407 |
+
}
|
| 1408 |
+
|
| 1409 |
+
if (String(request.body.model).endsWith('-pro-1.1')) {
|
| 1410 |
+
delete requestBody.steps;
|
| 1411 |
+
delete requestBody.guidance;
|
| 1412 |
+
}
|
| 1413 |
+
|
| 1414 |
+
console.debug('BFL request:', requestBody);
|
| 1415 |
+
|
| 1416 |
+
const result = await fetch(`https://api.bfl.ml/v1/${request.body.model}`, {
|
| 1417 |
+
method: 'POST',
|
| 1418 |
+
body: JSON.stringify(requestBody),
|
| 1419 |
+
headers: {
|
| 1420 |
+
'Content-Type': 'application/json',
|
| 1421 |
+
'x-key': key,
|
| 1422 |
+
},
|
| 1423 |
+
});
|
| 1424 |
+
|
| 1425 |
+
if (!result.ok) {
|
| 1426 |
+
console.warn('BFL returned an error.');
|
| 1427 |
+
return response.sendStatus(500);
|
| 1428 |
+
}
|
| 1429 |
+
|
| 1430 |
+
/** @type {any} */
|
| 1431 |
+
const taskData = await result.json();
|
| 1432 |
+
const { id } = taskData;
|
| 1433 |
+
|
| 1434 |
+
const MAX_ATTEMPTS = 100;
|
| 1435 |
+
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
| 1436 |
+
await delay(2500);
|
| 1437 |
+
|
| 1438 |
+
const statusResult = await fetch(`https://api.bfl.ml/v1/get_result?id=${id}`);
|
| 1439 |
+
|
| 1440 |
+
if (!statusResult.ok) {
|
| 1441 |
+
const text = await statusResult.text();
|
| 1442 |
+
console.warn('BFL returned an error.', text);
|
| 1443 |
+
return response.sendStatus(500);
|
| 1444 |
+
}
|
| 1445 |
+
|
| 1446 |
+
/** @type {any} */
|
| 1447 |
+
const statusData = await statusResult.json();
|
| 1448 |
+
|
| 1449 |
+
if (statusData?.status === 'Pending') {
|
| 1450 |
+
continue;
|
| 1451 |
+
}
|
| 1452 |
+
|
| 1453 |
+
if (statusData?.status === 'Ready') {
|
| 1454 |
+
const { sample } = statusData.result;
|
| 1455 |
+
const fetchResult = await fetch(sample);
|
| 1456 |
+
const fetchData = await fetchResult.arrayBuffer();
|
| 1457 |
+
const image = Buffer.from(fetchData).toString('base64');
|
| 1458 |
+
return response.send({ image: image });
|
| 1459 |
+
}
|
| 1460 |
+
|
| 1461 |
+
throw new Error('BFL failed to generate image.', { cause: statusData });
|
| 1462 |
+
}
|
| 1463 |
+
} catch (error) {
|
| 1464 |
+
console.error(error);
|
| 1465 |
+
return response.sendStatus(500);
|
| 1466 |
+
}
|
| 1467 |
+
});
|
| 1468 |
+
|
| 1469 |
+
const falai = express.Router();
|
| 1470 |
+
|
| 1471 |
+
falai.post('/models', async (_request, response) => {
|
| 1472 |
+
try {
|
| 1473 |
+
const modelsUrl = new URL('https://fal.ai/api/models?categories=text-to-image');
|
| 1474 |
+
let page = 1;
|
| 1475 |
+
/** @type {any} */
|
| 1476 |
+
let modelsResponse;
|
| 1477 |
+
let models = [];
|
| 1478 |
+
|
| 1479 |
+
do {
|
| 1480 |
+
modelsUrl.searchParams.set('page', page.toString());
|
| 1481 |
+
const result = await fetch(modelsUrl);
|
| 1482 |
+
|
| 1483 |
+
if (!result.ok) {
|
| 1484 |
+
console.warn('FAL.AI returned an error.', result.status, result.statusText);
|
| 1485 |
+
throw new Error('FAL.AI request failed.');
|
| 1486 |
+
}
|
| 1487 |
+
|
| 1488 |
+
modelsResponse = await result.json();
|
| 1489 |
+
if (!('items' in modelsResponse) || !Array.isArray(modelsResponse.items)) {
|
| 1490 |
+
console.warn('FAL.AI returned invalid data.');
|
| 1491 |
+
throw new Error('FAL.AI request failed.');
|
| 1492 |
+
}
|
| 1493 |
+
|
| 1494 |
+
models = models.concat(
|
| 1495 |
+
modelsResponse.items.filter(
|
| 1496 |
+
x => (
|
| 1497 |
+
!x.title.toLowerCase().includes('inpainting') &&
|
| 1498 |
+
!x.title.toLowerCase().includes('control') &&
|
| 1499 |
+
!x.title.toLowerCase().includes('upscale') &&
|
| 1500 |
+
!x.title.toLowerCase().includes('lora')
|
| 1501 |
+
),
|
| 1502 |
+
),
|
| 1503 |
+
);
|
| 1504 |
+
|
| 1505 |
+
page = modelsResponse.page + 1;
|
| 1506 |
+
} while (modelsResponse != null && page < modelsResponse.pages);
|
| 1507 |
+
|
| 1508 |
+
const modelOptions = models
|
| 1509 |
+
.sort((a, b) => a.title.localeCompare(b.title))
|
| 1510 |
+
.map(x => ({ value: x.modelUrl.split('fal-ai/')[1], text: x.title }))
|
| 1511 |
+
.map(x => ({ ...x, text: `${x.text} (${x.value})` }));
|
| 1512 |
+
return response.send(modelOptions);
|
| 1513 |
+
} catch (error) {
|
| 1514 |
+
console.error(error);
|
| 1515 |
+
return response.sendStatus(500);
|
| 1516 |
+
}
|
| 1517 |
+
});
|
| 1518 |
+
|
| 1519 |
+
falai.post('/generate', async (request, response) => {
|
| 1520 |
+
try {
|
| 1521 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.FALAI);
|
| 1522 |
+
|
| 1523 |
+
if (!key) {
|
| 1524 |
+
console.warn('FAL.AI key not found.');
|
| 1525 |
+
return response.sendStatus(400);
|
| 1526 |
+
}
|
| 1527 |
+
|
| 1528 |
+
const requestBody = {
|
| 1529 |
+
prompt: request.body.prompt,
|
| 1530 |
+
image_size: { 'width': request.body.width, 'height': request.body.height },
|
| 1531 |
+
num_inference_steps: request.body.steps,
|
| 1532 |
+
seed: request.body.seed ?? null,
|
| 1533 |
+
guidance_scale: request.body.guidance,
|
| 1534 |
+
enable_safety_checker: false, // Disable general safety checks
|
| 1535 |
+
safety_tolerance: 6, // Make Flux the least strict
|
| 1536 |
+
};
|
| 1537 |
+
|
| 1538 |
+
console.debug('FAL.AI request:', requestBody);
|
| 1539 |
+
|
| 1540 |
+
const result = await fetch(`https://queue.fal.run/fal-ai/${request.body.model}`, {
|
| 1541 |
+
method: 'POST',
|
| 1542 |
+
body: JSON.stringify(requestBody),
|
| 1543 |
+
headers: {
|
| 1544 |
+
'Content-Type': 'application/json',
|
| 1545 |
+
'Authorization': `Key ${key}`,
|
| 1546 |
+
},
|
| 1547 |
+
});
|
| 1548 |
+
|
| 1549 |
+
if (!result.ok) {
|
| 1550 |
+
console.warn('FAL.AI returned an error.');
|
| 1551 |
+
return response.sendStatus(500);
|
| 1552 |
+
}
|
| 1553 |
+
|
| 1554 |
+
/** @type {any} */
|
| 1555 |
+
const taskData = await result.json();
|
| 1556 |
+
const { status_url } = taskData;
|
| 1557 |
+
|
| 1558 |
+
const MAX_ATTEMPTS = 100;
|
| 1559 |
+
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
| 1560 |
+
await delay(2500);
|
| 1561 |
+
|
| 1562 |
+
const statusResult = await fetch(status_url, {
|
| 1563 |
+
headers: {
|
| 1564 |
+
'Authorization': `Key ${key}`,
|
| 1565 |
+
},
|
| 1566 |
+
});
|
| 1567 |
+
|
| 1568 |
+
if (!statusResult.ok) {
|
| 1569 |
+
const text = await statusResult.text();
|
| 1570 |
+
console.warn('FAL.AI returned an error.', text);
|
| 1571 |
+
return response.sendStatus(500);
|
| 1572 |
+
}
|
| 1573 |
+
|
| 1574 |
+
/** @type {any} */
|
| 1575 |
+
const statusData = await statusResult.json();
|
| 1576 |
+
|
| 1577 |
+
if (statusData?.status === 'IN_QUEUE' || statusData?.status === 'IN_PROGRESS') {
|
| 1578 |
+
continue;
|
| 1579 |
+
}
|
| 1580 |
+
|
| 1581 |
+
if (statusData?.status === 'COMPLETED') {
|
| 1582 |
+
const resultFetch = await fetch(statusData?.response_url, {
|
| 1583 |
+
method: 'GET',
|
| 1584 |
+
headers: {
|
| 1585 |
+
'Authorization': `Key ${key}`,
|
| 1586 |
+
},
|
| 1587 |
+
});
|
| 1588 |
+
/** @type {any} */
|
| 1589 |
+
const resultData = await resultFetch.json();
|
| 1590 |
+
|
| 1591 |
+
if (resultData.detail !== null && resultData.detail !== undefined) {
|
| 1592 |
+
throw new Error('FAL.AI failed to generate image.', { cause: `${resultData.detail[0].loc[1]}: ${resultData.detail[0].msg}` });
|
| 1593 |
+
}
|
| 1594 |
+
|
| 1595 |
+
const imageFetch = await fetch(resultData?.images[0].url, {
|
| 1596 |
+
headers: {
|
| 1597 |
+
'Authorization': `Key ${key}`,
|
| 1598 |
+
},
|
| 1599 |
+
});
|
| 1600 |
+
|
| 1601 |
+
const fetchData = await imageFetch.arrayBuffer();
|
| 1602 |
+
const image = Buffer.from(fetchData).toString('base64');
|
| 1603 |
+
return response.send({ image: image });
|
| 1604 |
+
}
|
| 1605 |
+
|
| 1606 |
+
throw new Error('FAL.AI failed to generate image.', { cause: statusData });
|
| 1607 |
+
}
|
| 1608 |
+
} catch (error) {
|
| 1609 |
+
console.error(error);
|
| 1610 |
+
return response.status(500).send(error.cause || error.message);
|
| 1611 |
+
}
|
| 1612 |
+
});
|
| 1613 |
+
|
| 1614 |
+
const xai = express.Router();
|
| 1615 |
+
|
| 1616 |
+
xai.post('/generate', async (request, response) => {
|
| 1617 |
+
try {
|
| 1618 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.XAI);
|
| 1619 |
+
|
| 1620 |
+
if (!key) {
|
| 1621 |
+
console.warn('xAI key not found.');
|
| 1622 |
+
return response.sendStatus(400);
|
| 1623 |
+
}
|
| 1624 |
+
|
| 1625 |
+
const requestBody = {
|
| 1626 |
+
prompt: request.body.prompt,
|
| 1627 |
+
model: request.body.model,
|
| 1628 |
+
response_format: 'b64_json',
|
| 1629 |
+
};
|
| 1630 |
+
|
| 1631 |
+
console.debug('xAI request:', requestBody);
|
| 1632 |
+
|
| 1633 |
+
const result = await fetch('https://api.x.ai/v1/images/generations', {
|
| 1634 |
+
method: 'POST',
|
| 1635 |
+
body: JSON.stringify(requestBody),
|
| 1636 |
+
headers: {
|
| 1637 |
+
'Content-Type': 'application/json',
|
| 1638 |
+
'Authorization': `Bearer ${key}`,
|
| 1639 |
+
},
|
| 1640 |
+
});
|
| 1641 |
+
|
| 1642 |
+
if (!result.ok) {
|
| 1643 |
+
const text = await result.text();
|
| 1644 |
+
console.warn('xAI returned an error.', text);
|
| 1645 |
+
return response.sendStatus(500);
|
| 1646 |
+
}
|
| 1647 |
+
|
| 1648 |
+
/** @type {any} */
|
| 1649 |
+
const data = await result.json();
|
| 1650 |
+
|
| 1651 |
+
const image = data?.data?.[0]?.b64_json;
|
| 1652 |
+
if (!image) {
|
| 1653 |
+
console.warn('xAI returned invalid data.');
|
| 1654 |
+
return response.sendStatus(500);
|
| 1655 |
+
}
|
| 1656 |
+
|
| 1657 |
+
return response.send({ image });
|
| 1658 |
+
} catch (error) {
|
| 1659 |
+
console.error('Error communicating with xAI', error);
|
| 1660 |
+
return response.sendStatus(500);
|
| 1661 |
+
}
|
| 1662 |
+
});
|
| 1663 |
+
|
| 1664 |
+
const aimlapi = express.Router();
|
| 1665 |
+
|
| 1666 |
+
aimlapi.post('/models', async (request, response) => {
|
| 1667 |
+
try {
|
| 1668 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.AIMLAPI);
|
| 1669 |
+
|
| 1670 |
+
if (!key) {
|
| 1671 |
+
console.warn('AI/ML API key not found.');
|
| 1672 |
+
return response.sendStatus(400);
|
| 1673 |
+
}
|
| 1674 |
+
|
| 1675 |
+
const modelsResponse = await fetch('https://api.aimlapi.com/v1/models', {
|
| 1676 |
+
method: 'GET',
|
| 1677 |
+
headers: {
|
| 1678 |
+
Authorization: `Bearer ${key}`,
|
| 1679 |
+
},
|
| 1680 |
+
});
|
| 1681 |
+
|
| 1682 |
+
if (!modelsResponse.ok) {
|
| 1683 |
+
console.warn('AI/ML API returned an error.');
|
| 1684 |
+
return response.sendStatus(500);
|
| 1685 |
+
}
|
| 1686 |
+
|
| 1687 |
+
/** @type {any} */
|
| 1688 |
+
const data = await modelsResponse.json();
|
| 1689 |
+
const models = (data.data || [])
|
| 1690 |
+
.filter(model =>
|
| 1691 |
+
model.type === 'image' &&
|
| 1692 |
+
model.id !== 'triposr' &&
|
| 1693 |
+
model.id !== 'flux/dev/image-to-image',
|
| 1694 |
+
)
|
| 1695 |
+
.map(model => ({
|
| 1696 |
+
value: model.id,
|
| 1697 |
+
text: model.info?.name || model.id,
|
| 1698 |
+
}));
|
| 1699 |
+
|
| 1700 |
+
return response.send({ data: models });
|
| 1701 |
+
} catch (error) {
|
| 1702 |
+
console.error(error);
|
| 1703 |
+
return response.sendStatus(500);
|
| 1704 |
+
}
|
| 1705 |
+
});
|
| 1706 |
+
|
| 1707 |
+
aimlapi.post('/generate-image', async (req, res) => {
|
| 1708 |
+
try {
|
| 1709 |
+
const key = readSecret(req.user.directories, SECRET_KEYS.AIMLAPI);
|
| 1710 |
+
if (!key) return res.sendStatus(400);
|
| 1711 |
+
|
| 1712 |
+
console.debug('AI/ML API image request:', req.body);
|
| 1713 |
+
|
| 1714 |
+
const apiRes = await fetch('https://api.aimlapi.com/v1/images/generations', {
|
| 1715 |
+
method: 'POST',
|
| 1716 |
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}`, ...AIMLAPI_HEADERS },
|
| 1717 |
+
body: JSON.stringify(req.body),
|
| 1718 |
+
});
|
| 1719 |
+
if (!apiRes.ok) {
|
| 1720 |
+
const err = await apiRes.text();
|
| 1721 |
+
return res.status(500).send(err);
|
| 1722 |
+
}
|
| 1723 |
+
/** @type {any} */
|
| 1724 |
+
const data = await apiRes.json();
|
| 1725 |
+
|
| 1726 |
+
const imgObj = Array.isArray(data.images) ? data.images[0] : data.data?.[0];
|
| 1727 |
+
if (!imgObj) return res.status(500).send('No image returned');
|
| 1728 |
+
|
| 1729 |
+
let base64;
|
| 1730 |
+
if (imgObj.b64_json || imgObj.base64) {
|
| 1731 |
+
base64 = imgObj.b64_json || imgObj.base64;
|
| 1732 |
+
} else if (imgObj.url) {
|
| 1733 |
+
const blobRes = await fetch(imgObj.url);
|
| 1734 |
+
if (!blobRes.ok) throw new Error('Failed to fetch image URL');
|
| 1735 |
+
const buffer = await blobRes.arrayBuffer();
|
| 1736 |
+
base64 = Buffer.from(buffer).toString('base64');
|
| 1737 |
+
} else {
|
| 1738 |
+
throw new Error('Unsupported image format');
|
| 1739 |
+
}
|
| 1740 |
+
|
| 1741 |
+
return res.json({ format: 'png', data: base64 });
|
| 1742 |
+
} catch (e) {
|
| 1743 |
+
console.error(e);
|
| 1744 |
+
res.status(500).send('Internal error');
|
| 1745 |
+
}
|
| 1746 |
+
});
|
| 1747 |
+
|
| 1748 |
+
const zai = express.Router();
|
| 1749 |
+
|
| 1750 |
+
zai.post('/generate', async (request, response) => {
|
| 1751 |
+
try {
|
| 1752 |
+
const key = readSecret(request.user.directories, SECRET_KEYS.ZAI);
|
| 1753 |
+
|
| 1754 |
+
if (!key) {
|
| 1755 |
+
console.warn('Z.AI key not found.');
|
| 1756 |
+
return response.sendStatus(400);
|
| 1757 |
+
}
|
| 1758 |
+
|
| 1759 |
+
console.debug('Z.AI image request:', request.body);
|
| 1760 |
+
|
| 1761 |
+
const generateResponse = await fetch('https://api.z.ai/api/paas/v4/images/generations', {
|
| 1762 |
+
method: 'POST',
|
| 1763 |
+
headers: {
|
| 1764 |
+
'Content-Type': 'application/json',
|
| 1765 |
+
'Authorization': `Bearer ${key}`,
|
| 1766 |
+
},
|
| 1767 |
+
body: JSON.stringify({
|
| 1768 |
+
prompt: request.body.prompt,
|
| 1769 |
+
model: request.body.model,
|
| 1770 |
+
quality: request.body.quality,
|
| 1771 |
+
size: request.body.size,
|
| 1772 |
+
}),
|
| 1773 |
+
});
|
| 1774 |
+
|
| 1775 |
+
if (!generateResponse.ok) {
|
| 1776 |
+
const text = await generateResponse.text();
|
| 1777 |
+
console.warn('Z.AI returned an error.', text);
|
| 1778 |
+
return response.sendStatus(500);
|
| 1779 |
+
}
|
| 1780 |
+
|
| 1781 |
+
/** @type {any} */
|
| 1782 |
+
const data = await generateResponse.json();
|
| 1783 |
+
console.debug('Z.AI image response:', data);
|
| 1784 |
+
|
| 1785 |
+
const url = data?.data?.[0]?.url;
|
| 1786 |
+
if (!url || !isValidUrl(url) || !new URL(url).hostname.endsWith('.z.ai')) {
|
| 1787 |
+
console.warn('Z.AI returned an invalid image URL.');
|
| 1788 |
+
return response.sendStatus(500);
|
| 1789 |
+
}
|
| 1790 |
+
|
| 1791 |
+
const imageResponse = await fetch(url);
|
| 1792 |
+
if (!imageResponse.ok) {
|
| 1793 |
+
console.warn('Z.AI image fetch returned an error.');
|
| 1794 |
+
return response.sendStatus(500);
|
| 1795 |
+
}
|
| 1796 |
+
|
| 1797 |
+
const buffer = await imageResponse.arrayBuffer();
|
| 1798 |
+
const image = Buffer.from(buffer).toString('base64');
|
| 1799 |
+
const format = path.extname(url).substring(1).toLowerCase() || 'png';
|
| 1800 |
+
|
| 1801 |
+
return response.send({ image, format });
|
| 1802 |
+
} catch (error) {
|
| 1803 |
+
console.error(error);
|
| 1804 |
+
return response.sendStatus(500);
|
| 1805 |
+
}
|
| 1806 |
+
});
|
| 1807 |
+
|
| 1808 |
+
router.use('/comfy', comfy);
|
| 1809 |
+
router.use('/comfyrunpod', comfyRunPod);
|
| 1810 |
+
router.use('/together', together);
|
| 1811 |
+
router.use('/drawthings', drawthings);
|
| 1812 |
+
router.use('/pollinations', pollinations);
|
| 1813 |
+
router.use('/stability', stability);
|
| 1814 |
+
router.use('/huggingface', huggingface);
|
| 1815 |
+
router.use('/chutes', chutes);
|
| 1816 |
+
router.use('/electronhub', electronhub);
|
| 1817 |
+
router.use('/nanogpt', nanogpt);
|
| 1818 |
+
router.use('/bfl', bfl);
|
| 1819 |
+
router.use('/falai', falai);
|
| 1820 |
+
router.use('/xai', xai);
|
| 1821 |
+
router.use('/aimlapi', aimlapi);
|
| 1822 |
+
router.use('/zai', zai);
|
src/endpoints/stats.js
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from 'node:fs';
|
| 2 |
+
import path from 'node:path';
|
| 3 |
+
import crypto from 'node:crypto';
|
| 4 |
+
|
| 5 |
+
import express from 'express';
|
| 6 |
+
import writeFileAtomic from 'write-file-atomic';
|
| 7 |
+
|
| 8 |
+
const readFile = fs.promises.readFile;
|
| 9 |
+
const readdir = fs.promises.readdir;
|
| 10 |
+
|
| 11 |
+
import { getAllUserHandles, getUserDirectories } from '../users.js';
|
| 12 |
+
|
| 13 |
+
const STATS_FILE = 'stats.json';
|
| 14 |
+
|
| 15 |
+
const monthNames = [
|
| 16 |
+
'January',
|
| 17 |
+
'February',
|
| 18 |
+
'March',
|
| 19 |
+
'April',
|
| 20 |
+
'May',
|
| 21 |
+
'June',
|
| 22 |
+
'July',
|
| 23 |
+
'August',
|
| 24 |
+
'September',
|
| 25 |
+
'October',
|
| 26 |
+
'November',
|
| 27 |
+
'December',
|
| 28 |
+
];
|
| 29 |
+
|
| 30 |
+
/**
|
| 31 |
+
* @type {Map<string, Object>} The stats object for each user.
|
| 32 |
+
*/
|
| 33 |
+
const STATS = new Map();
|
| 34 |
+
/**
|
| 35 |
+
* @type {Map<string, number>} The timestamps for each user.
|
| 36 |
+
*/
|
| 37 |
+
const TIMESTAMPS = new Map();
|
| 38 |
+
|
| 39 |
+
/**
|
| 40 |
+
* Convert a timestamp to an integer timestamp.
|
| 41 |
+
* This function can handle several different timestamp formats:
|
| 42 |
+
* 1. Date.now timestamps (the number of milliseconds since the Unix Epoch)
|
| 43 |
+
* 2. ST "humanized" timestamps, formatted like `YYYY-MM-DD@HHhMMmSSsMSms`
|
| 44 |
+
* 3. Date strings in the format `Month DD, YYYY H:MMam/pm`
|
| 45 |
+
* 4. ISO 8601 formatted strings
|
| 46 |
+
* 5. Date objects
|
| 47 |
+
*
|
| 48 |
+
* The function returns the timestamp as the number of milliseconds since
|
| 49 |
+
* the Unix Epoch, which can be converted to a JavaScript Date object with new Date().
|
| 50 |
+
*
|
| 51 |
+
* @param {string|number|Date} timestamp - The timestamp to convert.
|
| 52 |
+
* @returns {number} The timestamp in milliseconds since the Unix Epoch, or 0 if the input cannot be parsed.
|
| 53 |
+
*
|
| 54 |
+
* @example
|
| 55 |
+
* // Unix timestamp
|
| 56 |
+
* parseTimestamp(1609459200);
|
| 57 |
+
* // ST humanized timestamp
|
| 58 |
+
* parseTimestamp("2021-01-01 \@00h 00m 00s 000ms");
|
| 59 |
+
* // Date string
|
| 60 |
+
* parseTimestamp("January 1, 2021 12:00am");
|
| 61 |
+
*/
|
| 62 |
+
function parseTimestamp(timestamp) {
|
| 63 |
+
if (!timestamp) {
|
| 64 |
+
return 0;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
// Date object
|
| 68 |
+
if (timestamp instanceof Date) {
|
| 69 |
+
return timestamp.getTime();
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// Unix time
|
| 73 |
+
if (typeof timestamp === 'number' || /^\d+$/.test(timestamp)) {
|
| 74 |
+
const unixTime = Number(timestamp);
|
| 75 |
+
const isValid = Number.isFinite(unixTime) && !Number.isNaN(unixTime) && unixTime >= 0;
|
| 76 |
+
if (!isValid) return 0;
|
| 77 |
+
return new Date(unixTime).getTime();
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// ISO 8601 format
|
| 81 |
+
const isoPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
| 82 |
+
if (isoPattern.test(timestamp)) {
|
| 83 |
+
return new Date(timestamp).getTime();
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
let dateFormats = [];
|
| 87 |
+
|
| 88 |
+
// meridiem-based format
|
| 89 |
+
const convertFromMeridiemBased = (_, month, day, year, hour, minute, meridiem) => {
|
| 90 |
+
const monthNum = monthNames.indexOf(month) + 1;
|
| 91 |
+
const hour24 = meridiem.toLowerCase() === 'pm' ? (parseInt(hour, 10) % 12) + 12 : parseInt(hour, 10) % 12;
|
| 92 |
+
return `${year}-${monthNum}-${day.padStart(2, '0')}T${hour24.toString().padStart(2, '0')}:${minute.padStart(2, '0')}:00`;
|
| 93 |
+
};
|
| 94 |
+
// June 19, 2023 2:20pm
|
| 95 |
+
dateFormats.push({ callback: convertFromMeridiemBased, pattern: /(\w+)\s(\d{1,2}),\s(\d{4})\s(\d{1,2}):(\d{1,2})(am|pm)/i });
|
| 96 |
+
|
| 97 |
+
// ST "humanized" format patterns
|
| 98 |
+
const convertFromHumanized = (_, year, month, day, hour, min, sec, ms) => {
|
| 99 |
+
ms = typeof ms !== 'undefined' ? `.${ms.padStart(3, '0')}` : '';
|
| 100 |
+
return `${year.padStart(4, '0')}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${hour.padStart(2, '0')}:${min.padStart(2, '0')}:${sec.padStart(2, '0')}${ms}Z`;
|
| 101 |
+
};
|
| 102 |
+
// 2024-07-12@01h31m37s123ms
|
| 103 |
+
dateFormats.push({ callback: convertFromHumanized, pattern: /(\d{4})-(\d{1,2})-(\d{1,2})@(\d{1,2})h(\d{1,2})m(\d{1,2})s(\d{1,3})ms/ });
|
| 104 |
+
// 2024-7-12@01h31m37s
|
| 105 |
+
dateFormats.push({ callback: convertFromHumanized, pattern: /(\d{4})-(\d{1,2})-(\d{1,2})@(\d{1,2})h(\d{1,2})m(\d{1,2})s/ });
|
| 106 |
+
// 2024-6-5 @14h 56m 50s 682ms
|
| 107 |
+
dateFormats.push({ callback: convertFromHumanized, pattern: /(\d{4})-(\d{1,2})-(\d{1,2}) @(\d{1,2})h (\d{1,2})m (\d{1,2})s (\d{1,3})ms/ });
|
| 108 |
+
|
| 109 |
+
for (const x of dateFormats) {
|
| 110 |
+
const rgxMatch = timestamp.match(x.pattern);
|
| 111 |
+
if (!rgxMatch) continue;
|
| 112 |
+
const isoTimestamp = x.callback(...rgxMatch);
|
| 113 |
+
return new Date(isoTimestamp).getTime();
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
return 0;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/**
|
| 120 |
+
* Collects and aggregates stats for all characters.
|
| 121 |
+
*
|
| 122 |
+
* @param {string} chatsPath - The path to the directory containing the chat files.
|
| 123 |
+
* @param {string} charactersPath - The path to the directory containing the character files.
|
| 124 |
+
* @returns {Promise<Object>} The aggregated stats object.
|
| 125 |
+
*/
|
| 126 |
+
async function collectAndCreateStats(chatsPath, charactersPath) {
|
| 127 |
+
const files = await readdir(charactersPath);
|
| 128 |
+
|
| 129 |
+
const pngFiles = files.filter((file) => file.endsWith('.png'));
|
| 130 |
+
|
| 131 |
+
let processingPromises = pngFiles.map((file) =>
|
| 132 |
+
calculateStats(chatsPath, file),
|
| 133 |
+
);
|
| 134 |
+
const statsArr = await Promise.all(processingPromises);
|
| 135 |
+
|
| 136 |
+
let finalStats = {};
|
| 137 |
+
for (let stat of statsArr) {
|
| 138 |
+
finalStats = { ...finalStats, ...stat };
|
| 139 |
+
}
|
| 140 |
+
// tag with timestamp on when stats were generated
|
| 141 |
+
finalStats.timestamp = Date.now();
|
| 142 |
+
return finalStats;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
/**
|
| 146 |
+
* Recreates the stats object for a user.
|
| 147 |
+
* @param {string} handle User handle
|
| 148 |
+
* @param {string} chatsPath Path to the directory containing the chat files.
|
| 149 |
+
* @param {string} charactersPath Path to the directory containing the character files.
|
| 150 |
+
*/
|
| 151 |
+
export async function recreateStats(handle, chatsPath, charactersPath) {
|
| 152 |
+
console.info('Collecting and creating stats for user:', handle);
|
| 153 |
+
const stats = await collectAndCreateStats(chatsPath, charactersPath);
|
| 154 |
+
STATS.set(handle, stats);
|
| 155 |
+
await saveStatsToFile();
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/**
|
| 159 |
+
* Loads the stats file into memory. If the file doesn't exist or is invalid,
|
| 160 |
+
* initializes stats by collecting and creating them for each character.
|
| 161 |
+
*/
|
| 162 |
+
export async function init() {
|
| 163 |
+
try {
|
| 164 |
+
const userHandles = await getAllUserHandles();
|
| 165 |
+
for (const handle of userHandles) {
|
| 166 |
+
const directories = getUserDirectories(handle);
|
| 167 |
+
try {
|
| 168 |
+
const statsFilePath = path.join(directories.root, STATS_FILE);
|
| 169 |
+
const statsFileContent = await readFile(statsFilePath, 'utf-8');
|
| 170 |
+
STATS.set(handle, JSON.parse(statsFileContent));
|
| 171 |
+
} catch (err) {
|
| 172 |
+
// If the file doesn't exist or is invalid, initialize stats
|
| 173 |
+
if (err.code === 'ENOENT' || err instanceof SyntaxError) {
|
| 174 |
+
await recreateStats(handle, directories.chats, directories.characters);
|
| 175 |
+
} else {
|
| 176 |
+
throw err; // Rethrow the error if it's something we didn't expect
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
} catch (err) {
|
| 181 |
+
console.error('Failed to initialize stats:', err);
|
| 182 |
+
}
|
| 183 |
+
// Save stats every 5 minutes
|
| 184 |
+
setInterval(saveStatsToFile, 5 * 60 * 1000);
|
| 185 |
+
}
|
| 186 |
+
/**
|
| 187 |
+
* Saves the current state of charStats to a file, only if the data has changed since the last save.
|
| 188 |
+
*/
|
| 189 |
+
async function saveStatsToFile() {
|
| 190 |
+
const userHandles = await getAllUserHandles();
|
| 191 |
+
for (const handle of userHandles) {
|
| 192 |
+
if (!STATS.has(handle)) {
|
| 193 |
+
continue;
|
| 194 |
+
}
|
| 195 |
+
const charStats = STATS.get(handle);
|
| 196 |
+
const lastSaveTimestamp = TIMESTAMPS.get(handle) || 0;
|
| 197 |
+
if (charStats.timestamp > lastSaveTimestamp) {
|
| 198 |
+
try {
|
| 199 |
+
const directories = getUserDirectories(handle);
|
| 200 |
+
const statsFilePath = path.join(directories.root, STATS_FILE);
|
| 201 |
+
await writeFileAtomic(statsFilePath, JSON.stringify(charStats));
|
| 202 |
+
TIMESTAMPS.set(handle, Date.now());
|
| 203 |
+
} catch (error) {
|
| 204 |
+
console.error('Failed to save stats to file.', error);
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
/**
|
| 211 |
+
* Attempts to save charStats to a file and then terminates the process.
|
| 212 |
+
* If an error occurs during the file write, it logs the error before exiting.
|
| 213 |
+
*/
|
| 214 |
+
export async function onExit() {
|
| 215 |
+
try {
|
| 216 |
+
await saveStatsToFile();
|
| 217 |
+
} catch (err) {
|
| 218 |
+
console.error('Failed to write stats to file:', err);
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
/**
|
| 223 |
+
* Reads the contents of a file and returns the lines in the file as an array.
|
| 224 |
+
*
|
| 225 |
+
* @param {string} filepath - The path of the file to be read.
|
| 226 |
+
* @returns {Array<string>} - The lines in the file.
|
| 227 |
+
* @throws Will throw an error if the file cannot be read.
|
| 228 |
+
*/
|
| 229 |
+
function readAndParseFile(filepath) {
|
| 230 |
+
try {
|
| 231 |
+
let file = fs.readFileSync(filepath, 'utf8');
|
| 232 |
+
let lines = file.split('\n');
|
| 233 |
+
return lines;
|
| 234 |
+
} catch (error) {
|
| 235 |
+
console.error(`Error reading file at ${filepath}: ${error}`);
|
| 236 |
+
return [];
|
| 237 |
+
}
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
/**
|
| 241 |
+
* Calculates the time difference between two dates.
|
| 242 |
+
*
|
| 243 |
+
* @param {string} gen_started - The start time in ISO 8601 format.
|
| 244 |
+
* @param {string} gen_finished - The finish time in ISO 8601 format.
|
| 245 |
+
* @returns {number} - The difference in time in milliseconds.
|
| 246 |
+
*/
|
| 247 |
+
function calculateGenTime(gen_started, gen_finished) {
|
| 248 |
+
let startDate = new Date(gen_started);
|
| 249 |
+
let endDate = new Date(gen_finished);
|
| 250 |
+
return Number(endDate) - Number(startDate);
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
/**
|
| 254 |
+
* Counts the number of words in a string.
|
| 255 |
+
*
|
| 256 |
+
* @param {string} str - The string to count words in.
|
| 257 |
+
* @returns {number} - The number of words in the string.
|
| 258 |
+
*/
|
| 259 |
+
function countWordsInString(str) {
|
| 260 |
+
const match = str.match(/\b\w+\b/g);
|
| 261 |
+
return match ? match.length : 0;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
/**
|
| 265 |
+
* calculateStats - Calculate statistics for a given character chat directory.
|
| 266 |
+
*
|
| 267 |
+
* @param {string} chatsPath The directory containing the chat files.
|
| 268 |
+
* @param {string} item The name of the character.
|
| 269 |
+
* @return {object} An object containing the calculated statistics.
|
| 270 |
+
*/
|
| 271 |
+
const calculateStats = (chatsPath, item) => {
|
| 272 |
+
const chatDir = path.join(chatsPath, item.replace('.png', ''));
|
| 273 |
+
const stats = {
|
| 274 |
+
total_gen_time: 0,
|
| 275 |
+
user_word_count: 0,
|
| 276 |
+
non_user_word_count: 0,
|
| 277 |
+
user_msg_count: 0,
|
| 278 |
+
non_user_msg_count: 0,
|
| 279 |
+
total_swipe_count: 0,
|
| 280 |
+
chat_size: 0,
|
| 281 |
+
date_last_chat: 0,
|
| 282 |
+
date_first_chat: new Date('9999-12-31T23:59:59.999Z').getTime(),
|
| 283 |
+
};
|
| 284 |
+
let uniqueGenStartTimes = new Set();
|
| 285 |
+
|
| 286 |
+
if (fs.existsSync(chatDir)) {
|
| 287 |
+
const chats = fs.readdirSync(chatDir);
|
| 288 |
+
if (Array.isArray(chats) && chats.length) {
|
| 289 |
+
for (const chat of chats) {
|
| 290 |
+
const result = calculateTotalGenTimeAndWordCount(
|
| 291 |
+
chatDir,
|
| 292 |
+
chat,
|
| 293 |
+
uniqueGenStartTimes,
|
| 294 |
+
);
|
| 295 |
+
stats.total_gen_time += result.totalGenTime || 0;
|
| 296 |
+
stats.user_word_count += result.userWordCount || 0;
|
| 297 |
+
stats.non_user_word_count += result.nonUserWordCount || 0;
|
| 298 |
+
stats.user_msg_count += result.userMsgCount || 0;
|
| 299 |
+
stats.non_user_msg_count += result.nonUserMsgCount || 0;
|
| 300 |
+
stats.total_swipe_count += result.totalSwipeCount || 0;
|
| 301 |
+
|
| 302 |
+
const chatStat = fs.statSync(path.join(chatDir, chat));
|
| 303 |
+
stats.chat_size += chatStat.size;
|
| 304 |
+
stats.date_last_chat = Math.max(
|
| 305 |
+
stats.date_last_chat,
|
| 306 |
+
Math.floor(chatStat.mtimeMs),
|
| 307 |
+
);
|
| 308 |
+
stats.date_first_chat = Math.min(
|
| 309 |
+
stats.date_first_chat,
|
| 310 |
+
result.firstChatTime,
|
| 311 |
+
);
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
return { [item]: stats };
|
| 317 |
+
};
|
| 318 |
+
|
| 319 |
+
/**
|
| 320 |
+
* Sets the current charStats object.
|
| 321 |
+
* @param {string} handle - The user handle.
|
| 322 |
+
* @param {Object} stats - The new charStats object.
|
| 323 |
+
**/
|
| 324 |
+
function setCharStats(handle, stats) {
|
| 325 |
+
stats.timestamp = Date.now();
|
| 326 |
+
STATS.set(handle, stats);
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
/**
|
| 330 |
+
* Calculates the total generation time and word count for a chat with a character.
|
| 331 |
+
*
|
| 332 |
+
* @param {string} chatDir - The directory path where character chat files are stored.
|
| 333 |
+
* @param {string} chat - The name of the chat file.
|
| 334 |
+
* @returns {Object} - An object containing the total generation time, user word count, and non-user word count.
|
| 335 |
+
* @throws Will throw an error if the file cannot be read or parsed.
|
| 336 |
+
*/
|
| 337 |
+
function calculateTotalGenTimeAndWordCount(
|
| 338 |
+
chatDir,
|
| 339 |
+
chat,
|
| 340 |
+
uniqueGenStartTimes,
|
| 341 |
+
) {
|
| 342 |
+
let filepath = path.join(chatDir, chat);
|
| 343 |
+
let lines = readAndParseFile(filepath);
|
| 344 |
+
|
| 345 |
+
let totalGenTime = 0;
|
| 346 |
+
let userWordCount = 0;
|
| 347 |
+
let nonUserWordCount = 0;
|
| 348 |
+
let nonUserMsgCount = 0;
|
| 349 |
+
let userMsgCount = 0;
|
| 350 |
+
let totalSwipeCount = 0;
|
| 351 |
+
let firstChatTime = new Date('9999-12-31T23:59:59.999Z').getTime();
|
| 352 |
+
|
| 353 |
+
for (let line of lines) {
|
| 354 |
+
if (line.length) {
|
| 355 |
+
try {
|
| 356 |
+
let json = JSON.parse(line);
|
| 357 |
+
if (json.mes) {
|
| 358 |
+
let hash = crypto
|
| 359 |
+
.createHash('sha256')
|
| 360 |
+
.update(json.mes)
|
| 361 |
+
.digest('hex');
|
| 362 |
+
if (uniqueGenStartTimes.has(hash)) {
|
| 363 |
+
continue;
|
| 364 |
+
}
|
| 365 |
+
if (hash) {
|
| 366 |
+
uniqueGenStartTimes.add(hash);
|
| 367 |
+
}
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
if (json.gen_started && json.gen_finished) {
|
| 371 |
+
let genTime = calculateGenTime(
|
| 372 |
+
json.gen_started,
|
| 373 |
+
json.gen_finished,
|
| 374 |
+
);
|
| 375 |
+
totalGenTime += genTime;
|
| 376 |
+
|
| 377 |
+
if (json.swipes && !json.swipe_info) {
|
| 378 |
+
// If there are swipes but no swipe_info, estimate the genTime
|
| 379 |
+
totalGenTime += genTime * json.swipes.length;
|
| 380 |
+
}
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
if (json.mes) {
|
| 384 |
+
let wordCount = countWordsInString(json.mes);
|
| 385 |
+
json.is_user
|
| 386 |
+
? (userWordCount += wordCount)
|
| 387 |
+
: (nonUserWordCount += wordCount);
|
| 388 |
+
json.is_user ? userMsgCount++ : nonUserMsgCount++;
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
if (json.swipes && json.swipes.length > 1) {
|
| 392 |
+
totalSwipeCount += json.swipes.length - 1; // Subtract 1 to not count the first swipe
|
| 393 |
+
for (let i = 1; i < json.swipes.length; i++) {
|
| 394 |
+
// Start from the second swipe
|
| 395 |
+
let swipeText = json.swipes[i];
|
| 396 |
+
|
| 397 |
+
let wordCount = countWordsInString(swipeText);
|
| 398 |
+
json.is_user
|
| 399 |
+
? (userWordCount += wordCount)
|
| 400 |
+
: (nonUserWordCount += wordCount);
|
| 401 |
+
json.is_user ? userMsgCount++ : nonUserMsgCount++;
|
| 402 |
+
}
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
if (json.swipe_info && json.swipe_info.length > 1) {
|
| 406 |
+
for (let i = 1; i < json.swipe_info.length; i++) {
|
| 407 |
+
// Start from the second swipe
|
| 408 |
+
let swipe = json.swipe_info[i];
|
| 409 |
+
if (swipe.gen_started && swipe.gen_finished) {
|
| 410 |
+
totalGenTime += calculateGenTime(
|
| 411 |
+
swipe.gen_started,
|
| 412 |
+
swipe.gen_finished,
|
| 413 |
+
);
|
| 414 |
+
}
|
| 415 |
+
}
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
// If this is the first user message, set the first chat time
|
| 419 |
+
if (json.is_user) {
|
| 420 |
+
//get min between firstChatTime and timestampToMoment(json.send_date)
|
| 421 |
+
firstChatTime = Math.min(parseTimestamp(json.send_date), firstChatTime);
|
| 422 |
+
}
|
| 423 |
+
} catch (error) {
|
| 424 |
+
console.error(`Error parsing line ${line}: ${error}`);
|
| 425 |
+
}
|
| 426 |
+
}
|
| 427 |
+
}
|
| 428 |
+
return {
|
| 429 |
+
totalGenTime,
|
| 430 |
+
userWordCount,
|
| 431 |
+
nonUserWordCount,
|
| 432 |
+
userMsgCount,
|
| 433 |
+
nonUserMsgCount,
|
| 434 |
+
totalSwipeCount,
|
| 435 |
+
firstChatTime,
|
| 436 |
+
};
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
export const router = express.Router();
|
| 440 |
+
|
| 441 |
+
/**
|
| 442 |
+
* Handle a POST request to get the stats object
|
| 443 |
+
*/
|
| 444 |
+
router.post('/get', function (request, response) {
|
| 445 |
+
const stats = STATS.get(request.user.profile.handle) || {};
|
| 446 |
+
response.send(stats);
|
| 447 |
+
});
|
| 448 |
+
|
| 449 |
+
/**
|
| 450 |
+
* Triggers the recreation of statistics from chat files.
|
| 451 |
+
*/
|
| 452 |
+
router.post('/recreate', async function (request, response) {
|
| 453 |
+
try {
|
| 454 |
+
await recreateStats(request.user.profile.handle, request.user.directories.chats, request.user.directories.characters);
|
| 455 |
+
return response.sendStatus(200);
|
| 456 |
+
} catch (error) {
|
| 457 |
+
console.error(error);
|
| 458 |
+
return response.sendStatus(500);
|
| 459 |
+
}
|
| 460 |
+
});
|
| 461 |
+
|
| 462 |
+
/**
|
| 463 |
+
* Handle a POST request to update the stats object
|
| 464 |
+
*/
|
| 465 |
+
router.post('/update', function (request, response) {
|
| 466 |
+
if (!request.body) return response.sendStatus(400);
|
| 467 |
+
setCharStats(request.user.profile.handle, request.body);
|
| 468 |
+
return response.sendStatus(200);
|
| 469 |
+
});
|
src/endpoints/themes.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import path from 'node:path';
|
| 2 |
+
import fs from 'node:fs';
|
| 3 |
+
|
| 4 |
+
import express from 'express';
|
| 5 |
+
import sanitize from 'sanitize-filename';
|
| 6 |
+
import { sync as writeFileAtomicSync } from 'write-file-atomic';
|
| 7 |
+
|
| 8 |
+
export const router = express.Router();
|
| 9 |
+
|
| 10 |
+
router.post('/save', (request, response) => {
|
| 11 |
+
if (!request.body || !request.body.name) {
|
| 12 |
+
return response.sendStatus(400);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const filename = path.join(request.user.directories.themes, sanitize(`${request.body.name}.json`));
|
| 16 |
+
writeFileAtomicSync(filename, JSON.stringify(request.body, null, 4), 'utf8');
|
| 17 |
+
|
| 18 |
+
return response.sendStatus(200);
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
router.post('/delete', (request, response) => {
|
| 22 |
+
if (!request.body || !request.body.name) {
|
| 23 |
+
return response.sendStatus(400);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
try {
|
| 27 |
+
const filename = path.join(request.user.directories.themes, sanitize(`${request.body.name}.json`));
|
| 28 |
+
if (!fs.existsSync(filename)) {
|
| 29 |
+
console.error('Theme file not found:', filename);
|
| 30 |
+
return response.sendStatus(404);
|
| 31 |
+
}
|
| 32 |
+
fs.unlinkSync(filename);
|
| 33 |
+
return response.sendStatus(200);
|
| 34 |
+
} catch (error) {
|
| 35 |
+
console.error(error);
|
| 36 |
+
return response.sendStatus(500);
|
| 37 |
+
}
|
| 38 |
+
});
|
src/endpoints/thumbnails.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from 'node:fs';
|
| 2 |
+
import { promises as fsPromises } from 'node:fs';
|
| 3 |
+
import path from 'node:path';
|
| 4 |
+
|
| 5 |
+
import mime from 'mime-types';
|
| 6 |
+
import express from 'express';
|
| 7 |
+
import sanitize from 'sanitize-filename';
|
| 8 |
+
import { Jimp, JimpMime } from '../jimp.js';
|
| 9 |
+
import { sync as writeFileAtomicSync } from 'write-file-atomic';
|
| 10 |
+
|
| 11 |
+
import { getConfigValue, invalidateFirefoxCache } from '../util.js';
|
| 12 |
+
|
| 13 |
+
const thumbnailsEnabled = !!getConfigValue('thumbnails.enabled', true, 'boolean');
|
| 14 |
+
const quality = Math.min(100, Math.max(1, parseInt(getConfigValue('thumbnails.quality', 95, 'number'))));
|
| 15 |
+
const pngFormat = String(getConfigValue('thumbnails.format', 'jpg')).toLowerCase().trim() === 'png';
|
| 16 |
+
|
| 17 |
+
/**
|
| 18 |
+
* @typedef {'bg' | 'avatar' | 'persona'} ThumbnailType
|
| 19 |
+
*/
|
| 20 |
+
|
| 21 |
+
/** @type {Record<string, number[]>} */
|
| 22 |
+
export const dimensions = {
|
| 23 |
+
'bg': getConfigValue('thumbnails.dimensions.bg', [160, 90]),
|
| 24 |
+
'avatar': getConfigValue('thumbnails.dimensions.avatar', [96, 144]),
|
| 25 |
+
'persona': getConfigValue('thumbnails.dimensions.persona', [96, 144]),
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* Gets a path to thumbnail folder based on the type.
|
| 30 |
+
* @param {import('../users.js').UserDirectoryList} directories User directories
|
| 31 |
+
* @param {ThumbnailType} type Thumbnail type
|
| 32 |
+
* @returns {string} Path to the thumbnails folder
|
| 33 |
+
*/
|
| 34 |
+
function getThumbnailFolder(directories, type) {
|
| 35 |
+
let thumbnailFolder;
|
| 36 |
+
|
| 37 |
+
switch (type) {
|
| 38 |
+
case 'bg':
|
| 39 |
+
thumbnailFolder = directories.thumbnailsBg;
|
| 40 |
+
break;
|
| 41 |
+
case 'avatar':
|
| 42 |
+
thumbnailFolder = directories.thumbnailsAvatar;
|
| 43 |
+
break;
|
| 44 |
+
case 'persona':
|
| 45 |
+
thumbnailFolder = directories.thumbnailsPersona;
|
| 46 |
+
break;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
return thumbnailFolder;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/**
|
| 53 |
+
* Gets a path to the original images folder based on the type.
|
| 54 |
+
* @param {import('../users.js').UserDirectoryList} directories User directories
|
| 55 |
+
* @param {ThumbnailType} type Thumbnail type
|
| 56 |
+
* @returns {string} Path to the original images folder
|
| 57 |
+
*/
|
| 58 |
+
function getOriginalFolder(directories, type) {
|
| 59 |
+
let originalFolder;
|
| 60 |
+
|
| 61 |
+
switch (type) {
|
| 62 |
+
case 'bg':
|
| 63 |
+
originalFolder = directories.backgrounds;
|
| 64 |
+
break;
|
| 65 |
+
case 'avatar':
|
| 66 |
+
originalFolder = directories.characters;
|
| 67 |
+
break;
|
| 68 |
+
case 'persona':
|
| 69 |
+
originalFolder = directories.avatars;
|
| 70 |
+
break;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
return originalFolder;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
/**
|
| 77 |
+
* Removes the generated thumbnail from the disk.
|
| 78 |
+
* @param {import('../users.js').UserDirectoryList} directories User directories
|
| 79 |
+
* @param {ThumbnailType} type Type of the thumbnail
|
| 80 |
+
* @param {string} file Name of the file
|
| 81 |
+
*/
|
| 82 |
+
export function invalidateThumbnail(directories, type, file) {
|
| 83 |
+
const folder = getThumbnailFolder(directories, type);
|
| 84 |
+
if (folder === undefined) throw new Error('Invalid thumbnail type');
|
| 85 |
+
|
| 86 |
+
const pathToThumbnail = path.join(folder, sanitize(file));
|
| 87 |
+
|
| 88 |
+
if (fs.existsSync(pathToThumbnail)) {
|
| 89 |
+
fs.unlinkSync(pathToThumbnail);
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
/**
|
| 94 |
+
* Generates a thumbnail for the given file.
|
| 95 |
+
* @param {import('../users.js').UserDirectoryList} directories User directories
|
| 96 |
+
* @param {ThumbnailType} type Type of the thumbnail
|
| 97 |
+
* @param {string} file Name of the file
|
| 98 |
+
* @returns
|
| 99 |
+
*/
|
| 100 |
+
async function generateThumbnail(directories, type, file) {
|
| 101 |
+
let thumbnailFolder = getThumbnailFolder(directories, type);
|
| 102 |
+
let originalFolder = getOriginalFolder(directories, type);
|
| 103 |
+
if (thumbnailFolder === undefined || originalFolder === undefined) throw new Error('Invalid thumbnail type');
|
| 104 |
+
const pathToCachedFile = path.join(thumbnailFolder, file);
|
| 105 |
+
const pathToOriginalFile = path.join(originalFolder, file);
|
| 106 |
+
|
| 107 |
+
const cachedFileExists = fs.existsSync(pathToCachedFile);
|
| 108 |
+
const originalFileExists = fs.existsSync(pathToOriginalFile);
|
| 109 |
+
|
| 110 |
+
// to handle cases when original image was updated after thumb creation
|
| 111 |
+
let shouldRegenerate = false;
|
| 112 |
+
|
| 113 |
+
if (cachedFileExists && originalFileExists) {
|
| 114 |
+
const originalStat = fs.statSync(pathToOriginalFile);
|
| 115 |
+
const cachedStat = fs.statSync(pathToCachedFile);
|
| 116 |
+
|
| 117 |
+
if (originalStat.mtimeMs > cachedStat.ctimeMs) {
|
| 118 |
+
//console.warn('Original file changed. Regenerating thumbnail...');
|
| 119 |
+
shouldRegenerate = true;
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
if (cachedFileExists && !shouldRegenerate) {
|
| 124 |
+
return pathToCachedFile;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
if (!originalFileExists) {
|
| 128 |
+
return null;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
try {
|
| 132 |
+
let buffer;
|
| 133 |
+
|
| 134 |
+
try {
|
| 135 |
+
const size = dimensions[type];
|
| 136 |
+
const image = await Jimp.read(pathToOriginalFile);
|
| 137 |
+
const width = !isNaN(size?.[0]) && size?.[0] > 0 ? size[0] : image.bitmap.width;
|
| 138 |
+
const height = !isNaN(size?.[1]) && size?.[1] > 0 ? size[1] : image.bitmap.height;
|
| 139 |
+
image.cover({ w: width, h: height });
|
| 140 |
+
buffer = pngFormat
|
| 141 |
+
? await image.getBuffer(JimpMime.png)
|
| 142 |
+
: await image.getBuffer(JimpMime.jpeg, { quality: quality, jpegColorSpace: 'ycbcr' });
|
| 143 |
+
}
|
| 144 |
+
catch (inner) {
|
| 145 |
+
console.warn(`Thumbnailer can not process the image: ${pathToOriginalFile}. Using original size`, inner);
|
| 146 |
+
buffer = fs.readFileSync(pathToOriginalFile);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
writeFileAtomicSync(pathToCachedFile, buffer);
|
| 150 |
+
}
|
| 151 |
+
catch (outer) {
|
| 152 |
+
return null;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
return pathToCachedFile;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/**
|
| 159 |
+
* Ensures that the thumbnail cache for backgrounds is valid.
|
| 160 |
+
* @param {import('../users.js').UserDirectoryList[]} directoriesList User directories
|
| 161 |
+
* @returns {Promise<void>} Promise that resolves when the cache is validated
|
| 162 |
+
*/
|
| 163 |
+
export async function ensureThumbnailCache(directoriesList) {
|
| 164 |
+
for (const directories of directoriesList) {
|
| 165 |
+
const cacheFiles = fs.readdirSync(directories.thumbnailsBg);
|
| 166 |
+
|
| 167 |
+
// files exist, all ok
|
| 168 |
+
if (cacheFiles.length) {
|
| 169 |
+
continue;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
console.info('Generating thumbnails cache. Please wait...');
|
| 173 |
+
|
| 174 |
+
const bgFiles = fs.readdirSync(directories.backgrounds);
|
| 175 |
+
const tasks = [];
|
| 176 |
+
|
| 177 |
+
for (const file of bgFiles) {
|
| 178 |
+
tasks.push(generateThumbnail(directories, 'bg', file));
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
await Promise.all(tasks);
|
| 182 |
+
console.info(`Done! Generated: ${bgFiles.length} preview images`);
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
export const router = express.Router();
|
| 187 |
+
|
| 188 |
+
// Important: This route must be mounted as '/thumbnail'. It is used in the client code and saved to chat files.
|
| 189 |
+
router.get('/', async function (request, response) {
|
| 190 |
+
try{
|
| 191 |
+
if (typeof request.query.file !== 'string' || typeof request.query.type !== 'string') {
|
| 192 |
+
return response.sendStatus(400);
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
const type = request.query.type;
|
| 196 |
+
const file = sanitize(request.query.file);
|
| 197 |
+
|
| 198 |
+
if (!type || !file) {
|
| 199 |
+
return response.sendStatus(400);
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
if (!(type === 'bg' || type === 'avatar' || type === 'persona')) {
|
| 203 |
+
return response.sendStatus(400);
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
if (sanitize(file) !== file) {
|
| 207 |
+
console.error('Malicious filename prevented');
|
| 208 |
+
return response.sendStatus(403);
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
if (!thumbnailsEnabled) {
|
| 212 |
+
const folder = getOriginalFolder(request.user.directories, type);
|
| 213 |
+
|
| 214 |
+
if (folder === undefined) {
|
| 215 |
+
return response.sendStatus(400);
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
const pathToOriginalFile = path.join(folder, file);
|
| 219 |
+
if (!fs.existsSync(pathToOriginalFile)) {
|
| 220 |
+
return response.sendStatus(404);
|
| 221 |
+
}
|
| 222 |
+
const contentType = mime.lookup(pathToOriginalFile) || 'image/png';
|
| 223 |
+
const originalFile = await fsPromises.readFile(pathToOriginalFile);
|
| 224 |
+
response.setHeader('Content-Type', contentType);
|
| 225 |
+
|
| 226 |
+
invalidateFirefoxCache(pathToOriginalFile, request, response);
|
| 227 |
+
|
| 228 |
+
return response.send(originalFile);
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
const pathToCachedFile = await generateThumbnail(request.user.directories, type, file);
|
| 232 |
+
|
| 233 |
+
if (!pathToCachedFile) {
|
| 234 |
+
return response.sendStatus(404);
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
if (!fs.existsSync(pathToCachedFile)) {
|
| 238 |
+
return response.sendStatus(404);
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
const contentType = mime.lookup(pathToCachedFile) || 'image/jpeg';
|
| 242 |
+
const cachedFile = await fsPromises.readFile(pathToCachedFile);
|
| 243 |
+
response.setHeader('Content-Type', contentType);
|
| 244 |
+
|
| 245 |
+
invalidateFirefoxCache(file, request, response);
|
| 246 |
+
|
| 247 |
+
return response.send(cachedFile);
|
| 248 |
+
} catch (error) {
|
| 249 |
+
console.error('Failed getting thumbnail', error);
|
| 250 |
+
return response.sendStatus(500);
|
| 251 |
+
}
|
| 252 |
+
});
|
src/endpoints/tokenizers.js
ADDED
|
@@ -0,0 +1,1128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from 'node:fs';
|
| 2 |
+
import path from 'node:path';
|
| 3 |
+
import { Buffer } from 'node:buffer';
|
| 4 |
+
import zlib from 'node:zlib';
|
| 5 |
+
import { promisify } from 'node:util';
|
| 6 |
+
|
| 7 |
+
import express from 'express';
|
| 8 |
+
import fetch from 'node-fetch';
|
| 9 |
+
import { sync as writeFileAtomicSync } from 'write-file-atomic';
|
| 10 |
+
|
| 11 |
+
import { Tokenizer } from '@agnai/web-tokenizers';
|
| 12 |
+
import { SentencePieceProcessor } from '@agnai/sentencepiece-js';
|
| 13 |
+
import tiktoken from 'tiktoken';
|
| 14 |
+
|
| 15 |
+
import { convertClaudePrompt } from '../prompt-converters.js';
|
| 16 |
+
import { TEXTGEN_TYPES } from '../constants.js';
|
| 17 |
+
import { setAdditionalHeaders } from '../additional-headers.js';
|
| 18 |
+
import { getConfigValue, isValidUrl } from '../util.js';
|
| 19 |
+
|
| 20 |
+
/**
|
| 21 |
+
* @typedef { (req: import('express').Request, res: import('express').Response) => Promise<any> } TokenizationHandler
|
| 22 |
+
*/
|
| 23 |
+
|
| 24 |
+
/**
|
| 25 |
+
* @type {{[key: string]: import('tiktoken').Tiktoken}} Tokenizers cache
|
| 26 |
+
*/
|
| 27 |
+
const tokenizersCache = {};
|
| 28 |
+
|
| 29 |
+
/**
|
| 30 |
+
* @type {string[]}
|
| 31 |
+
*/
|
| 32 |
+
export const TEXT_COMPLETION_MODELS = [
|
| 33 |
+
'gpt-3.5-turbo-instruct',
|
| 34 |
+
'gpt-3.5-turbo-instruct-0914',
|
| 35 |
+
'text-davinci-003',
|
| 36 |
+
'text-davinci-002',
|
| 37 |
+
'text-davinci-001',
|
| 38 |
+
'text-curie-001',
|
| 39 |
+
'text-babbage-001',
|
| 40 |
+
'text-ada-001',
|
| 41 |
+
'code-davinci-002',
|
| 42 |
+
'code-davinci-001',
|
| 43 |
+
'code-cushman-002',
|
| 44 |
+
'code-cushman-001',
|
| 45 |
+
'text-davinci-edit-001',
|
| 46 |
+
'code-davinci-edit-001',
|
| 47 |
+
'text-embedding-ada-002',
|
| 48 |
+
'text-similarity-davinci-001',
|
| 49 |
+
'text-similarity-curie-001',
|
| 50 |
+
'text-similarity-babbage-001',
|
| 51 |
+
'text-similarity-ada-001',
|
| 52 |
+
'text-search-davinci-doc-001',
|
| 53 |
+
'text-search-curie-doc-001',
|
| 54 |
+
'text-search-babbage-doc-001',
|
| 55 |
+
'text-search-ada-doc-001',
|
| 56 |
+
'code-search-babbage-code-001',
|
| 57 |
+
'code-search-ada-code-001',
|
| 58 |
+
];
|
| 59 |
+
|
| 60 |
+
const CHARS_PER_TOKEN = 3.35;
|
| 61 |
+
const IS_DOWNLOAD_ALLOWED = getConfigValue('enableDownloadableTokenizers', true, 'boolean');
|
| 62 |
+
const gunzip = promisify(zlib.gunzip);
|
| 63 |
+
|
| 64 |
+
/**
|
| 65 |
+
* Gets a path to the tokenizer model. Downloads the model if it's a URL.
|
| 66 |
+
* @param {string} model Model URL or path
|
| 67 |
+
* @param {string|undefined} fallbackModel Fallback model path
|
| 68 |
+
* @returns {Promise<string>} Path to the tokenizer model
|
| 69 |
+
*/
|
| 70 |
+
async function getPathToTokenizer(model, fallbackModel) {
|
| 71 |
+
if (!isValidUrl(model)) {
|
| 72 |
+
return model;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
try {
|
| 76 |
+
const url = new URL(model);
|
| 77 |
+
|
| 78 |
+
if (!['https:', 'http:'].includes(url.protocol)) {
|
| 79 |
+
throw new Error('Invalid URL protocol');
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
const fileName = url.pathname.split('/').pop();
|
| 83 |
+
|
| 84 |
+
if (!fileName) {
|
| 85 |
+
throw new Error('Failed to extract the file name from the URL');
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
const CACHE_PATH = path.join(globalThis.DATA_ROOT, '_cache');
|
| 89 |
+
if (!fs.existsSync(CACHE_PATH)) {
|
| 90 |
+
fs.mkdirSync(CACHE_PATH, { recursive: true });
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
// If an uncompressed version exists, return it
|
| 94 |
+
const isCompressed = path.extname(fileName) === '.gz';
|
| 95 |
+
const uncompressedName = path.basename(fileName, '.gz');
|
| 96 |
+
const uncompressedPath = path.join(CACHE_PATH, uncompressedName);
|
| 97 |
+
if (isCompressed && fs.existsSync(uncompressedPath)) {
|
| 98 |
+
return uncompressedPath;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
const cachedFile = path.join(CACHE_PATH, fileName);
|
| 102 |
+
if (fs.existsSync(cachedFile)) {
|
| 103 |
+
// If the file was downloaded manually
|
| 104 |
+
if (isCompressed) {
|
| 105 |
+
const compressedBuffer = await fs.promises.readFile(cachedFile);
|
| 106 |
+
const decompressedBuffer = await gunzip(compressedBuffer);
|
| 107 |
+
writeFileAtomicSync(uncompressedPath, decompressedBuffer);
|
| 108 |
+
await fs.promises.unlink(cachedFile);
|
| 109 |
+
return uncompressedPath;
|
| 110 |
+
}
|
| 111 |
+
return cachedFile;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
if (!IS_DOWNLOAD_ALLOWED) {
|
| 115 |
+
throw new Error('Downloading tokenizers is disabled, the model is not cached');
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
console.info('Downloading tokenizer model:', model);
|
| 119 |
+
const response = await fetch(model);
|
| 120 |
+
if (!response.ok) {
|
| 121 |
+
throw new Error(`Failed to fetch the model: ${response.status} ${response.statusText}`);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
const arrayBuffer = await response.arrayBuffer();
|
| 125 |
+
if (isCompressed) {
|
| 126 |
+
const decompressedBuffer = await gunzip(arrayBuffer);
|
| 127 |
+
writeFileAtomicSync(uncompressedPath, decompressedBuffer);
|
| 128 |
+
return uncompressedPath;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
writeFileAtomicSync(cachedFile, Buffer.from(arrayBuffer));
|
| 132 |
+
return cachedFile;
|
| 133 |
+
} catch (error) {
|
| 134 |
+
const getLastSegment = str => str?.split('/')?.pop() || '';
|
| 135 |
+
if (fallbackModel) {
|
| 136 |
+
console.error(`Could not get a tokenizer from ${getLastSegment(model)}. Reason: ${error.message}. Using a fallback model: ${getLastSegment(fallbackModel)}.`);
|
| 137 |
+
return fallbackModel;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
throw new Error(`Failed to instantiate a tokenizer and fallback is not provided. Reason: ${error.message}`);
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
/**
|
| 145 |
+
* Sentencepiece tokenizer for tokenizing text.
|
| 146 |
+
*/
|
| 147 |
+
class SentencePieceTokenizer {
|
| 148 |
+
/**
|
| 149 |
+
* @type {import('@agnai/sentencepiece-js').SentencePieceProcessor} Sentencepiece tokenizer instance
|
| 150 |
+
*/
|
| 151 |
+
#instance;
|
| 152 |
+
/**
|
| 153 |
+
* @type {string} Path to the tokenizer model
|
| 154 |
+
*/
|
| 155 |
+
#model;
|
| 156 |
+
/**
|
| 157 |
+
* @type {string|undefined} Path to the fallback model
|
| 158 |
+
*/
|
| 159 |
+
#fallbackModel;
|
| 160 |
+
|
| 161 |
+
/**
|
| 162 |
+
* Creates a new Sentencepiece tokenizer.
|
| 163 |
+
* @param {string} model Path to the tokenizer model
|
| 164 |
+
* @param {string} [fallbackModel] Path to the fallback model
|
| 165 |
+
*/
|
| 166 |
+
constructor(model, fallbackModel) {
|
| 167 |
+
this.#model = model;
|
| 168 |
+
this.#fallbackModel = fallbackModel;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
/**
|
| 172 |
+
* Gets the Sentencepiece tokenizer instance.
|
| 173 |
+
* @returns {Promise<import('@agnai/sentencepiece-js').SentencePieceProcessor|null>} Sentencepiece tokenizer instance
|
| 174 |
+
*/
|
| 175 |
+
async get() {
|
| 176 |
+
if (this.#instance) {
|
| 177 |
+
return this.#instance;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
try {
|
| 181 |
+
const pathToModel = await getPathToTokenizer(this.#model, this.#fallbackModel);
|
| 182 |
+
this.#instance = new SentencePieceProcessor();
|
| 183 |
+
await this.#instance.load(pathToModel);
|
| 184 |
+
console.info('Instantiated the tokenizer for', path.parse(pathToModel).name);
|
| 185 |
+
return this.#instance;
|
| 186 |
+
} catch (error) {
|
| 187 |
+
console.error('Sentencepiece tokenizer failed to load: ' + this.#model, error);
|
| 188 |
+
return null;
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
/**
|
| 194 |
+
* Web tokenizer for tokenizing text.
|
| 195 |
+
*/
|
| 196 |
+
class WebTokenizer {
|
| 197 |
+
/**
|
| 198 |
+
* @type {Tokenizer} Web tokenizer instance
|
| 199 |
+
*/
|
| 200 |
+
#instance;
|
| 201 |
+
/**
|
| 202 |
+
* @type {string} Path to the tokenizer model
|
| 203 |
+
*/
|
| 204 |
+
#model;
|
| 205 |
+
/**
|
| 206 |
+
* @type {string|undefined} Path to the fallback model
|
| 207 |
+
*/
|
| 208 |
+
#fallbackModel;
|
| 209 |
+
|
| 210 |
+
/**
|
| 211 |
+
* Creates a new Web tokenizer.
|
| 212 |
+
* @param {string} model Path to the tokenizer model
|
| 213 |
+
* @param {string} [fallbackModel] Path to the fallback model
|
| 214 |
+
*/
|
| 215 |
+
constructor(model, fallbackModel) {
|
| 216 |
+
this.#model = model;
|
| 217 |
+
this.#fallbackModel = fallbackModel;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
/**
|
| 221 |
+
* Gets the Web tokenizer instance.
|
| 222 |
+
* @returns {Promise<Tokenizer|null>} Web tokenizer instance
|
| 223 |
+
*/
|
| 224 |
+
async get() {
|
| 225 |
+
if (this.#instance) {
|
| 226 |
+
return this.#instance;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
try {
|
| 230 |
+
const pathToModel = await getPathToTokenizer(this.#model, this.#fallbackModel);
|
| 231 |
+
const fileBuffer = await fs.promises.readFile(pathToModel);
|
| 232 |
+
this.#instance = await Tokenizer.fromJSON(fileBuffer);
|
| 233 |
+
console.info('Instantiated the tokenizer for', path.parse(pathToModel).name);
|
| 234 |
+
return this.#instance;
|
| 235 |
+
} catch (error) {
|
| 236 |
+
console.error('Web tokenizer failed to load: ' + this.#model, error);
|
| 237 |
+
return null;
|
| 238 |
+
}
|
| 239 |
+
}
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
const spp_llama = new SentencePieceTokenizer('src/tokenizers/llama.model');
|
| 243 |
+
const spp_nerd = new SentencePieceTokenizer('src/tokenizers/nerdstash.model');
|
| 244 |
+
const spp_nerd_v2 = new SentencePieceTokenizer('src/tokenizers/nerdstash_v2.model');
|
| 245 |
+
const spp_mistral = new SentencePieceTokenizer('src/tokenizers/mistral.model');
|
| 246 |
+
const spp_yi = new SentencePieceTokenizer('src/tokenizers/yi.model');
|
| 247 |
+
const spp_gemma = new SentencePieceTokenizer('src/tokenizers/gemma.model');
|
| 248 |
+
const spp_jamba = new SentencePieceTokenizer('src/tokenizers/jamba.model');
|
| 249 |
+
const claude_tokenizer = new WebTokenizer('src/tokenizers/claude.json');
|
| 250 |
+
const llama3_tokenizer = new WebTokenizer('src/tokenizers/llama3.json');
|
| 251 |
+
const commandRTokenizer = new WebTokenizer('https://github.com/TavernIntern/TavernIntern-Tokenizers/raw/main/command-r.json.gz', 'src/tokenizers/llama3.json');
|
| 252 |
+
const commandATokenizer = new WebTokenizer('https://github.com/TavernIntern/TavernIntern-Tokenizers/raw/main/command-a.json.gz', 'src/tokenizers/llama3.json');
|
| 253 |
+
const qwen2Tokenizer = new WebTokenizer('https://github.com/TavernIntern/TavernIntern-Tokenizers/raw/main/qwen2.json.gz', 'src/tokenizers/llama3.json');
|
| 254 |
+
const nemoTokenizer = new WebTokenizer('https://github.com/TavernIntern/TavernIntern-Tokenizers/raw/main/nemo.json.gz', 'src/tokenizers/llama3.json');
|
| 255 |
+
const deepseekTokenizer = new WebTokenizer('https://github.com/TavernIntern/TavernIntern-Tokenizers/raw/main/deepseek.json.gz', 'src/tokenizers/llama3.json');
|
| 256 |
+
|
| 257 |
+
export const sentencepieceTokenizers = [
|
| 258 |
+
'llama',
|
| 259 |
+
'nerdstash',
|
| 260 |
+
'nerdstash_v2',
|
| 261 |
+
'mistral',
|
| 262 |
+
'yi',
|
| 263 |
+
'gemma',
|
| 264 |
+
'jamba',
|
| 265 |
+
];
|
| 266 |
+
|
| 267 |
+
export const webTokenizers = [
|
| 268 |
+
'claude',
|
| 269 |
+
'llama3',
|
| 270 |
+
'command-r',
|
| 271 |
+
'command-a',
|
| 272 |
+
'qwen2',
|
| 273 |
+
'nemo',
|
| 274 |
+
'deepseek',
|
| 275 |
+
];
|
| 276 |
+
|
| 277 |
+
/**
|
| 278 |
+
* Gets the Sentencepiece tokenizer by the model name.
|
| 279 |
+
* @param {string} model Sentencepiece model name
|
| 280 |
+
* @returns {SentencePieceTokenizer|null} Sentencepiece tokenizer
|
| 281 |
+
*/
|
| 282 |
+
export function getSentencepiceTokenizer(model) {
|
| 283 |
+
if (model.includes('llama')) {
|
| 284 |
+
return spp_llama;
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
if (model.includes('nerdstash')) {
|
| 288 |
+
return spp_nerd;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
if (model.includes('mistral')) {
|
| 292 |
+
return spp_mistral;
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
if (model.includes('nerdstash_v2')) {
|
| 296 |
+
return spp_nerd_v2;
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
if (model.includes('yi')) {
|
| 300 |
+
return spp_yi;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
if (model.includes('gemma')) {
|
| 304 |
+
return spp_gemma;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
if (model.includes('jamba')) {
|
| 308 |
+
return spp_jamba;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
return null;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
/**
|
| 315 |
+
* Gets the Web tokenizer by the model name.
|
| 316 |
+
* @param {string} model Web tokenizer model name
|
| 317 |
+
* @returns {WebTokenizer|null} Web tokenizer
|
| 318 |
+
*/
|
| 319 |
+
export function getWebTokenizer(model) {
|
| 320 |
+
if (model.includes('llama3')) {
|
| 321 |
+
return llama3_tokenizer;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
if (model.includes('claude')) {
|
| 325 |
+
return claude_tokenizer;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
if (model.includes('command-r')) {
|
| 329 |
+
return commandRTokenizer;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
if (model.includes('command-a')) {
|
| 333 |
+
return commandATokenizer;
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
if (model.includes('qwen2')) {
|
| 337 |
+
return qwen2Tokenizer;
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
if (model.includes('nemo')) {
|
| 341 |
+
return nemoTokenizer;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
if (model.includes('deepseek')) {
|
| 345 |
+
return deepseekTokenizer;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
return null;
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
/**
|
| 352 |
+
* Counts the token ids for the given text using the Sentencepiece tokenizer.
|
| 353 |
+
* @param {SentencePieceTokenizer} tokenizer Sentencepiece tokenizer
|
| 354 |
+
* @param {string} text Text to tokenize
|
| 355 |
+
* @returns { Promise<{ids: number[], count: number}> } Tokenization result
|
| 356 |
+
*/
|
| 357 |
+
async function countSentencepieceTokens(tokenizer, text) {
|
| 358 |
+
const instance = await tokenizer?.get();
|
| 359 |
+
|
| 360 |
+
// Fallback to strlen estimation
|
| 361 |
+
if (!instance) {
|
| 362 |
+
return {
|
| 363 |
+
ids: [],
|
| 364 |
+
count: Math.ceil(text.length / CHARS_PER_TOKEN),
|
| 365 |
+
};
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
let cleaned = text; // cleanText(text); <-- cleaning text can result in an incorrect tokenization
|
| 369 |
+
|
| 370 |
+
let ids = instance.encodeIds(cleaned);
|
| 371 |
+
return {
|
| 372 |
+
ids,
|
| 373 |
+
count: ids.length,
|
| 374 |
+
};
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
/**
|
| 378 |
+
* Counts the tokens in the given array of objects using the Sentencepiece tokenizer.
|
| 379 |
+
* @param {SentencePieceTokenizer} tokenizer
|
| 380 |
+
* @param {object[]} array Array of objects to tokenize
|
| 381 |
+
* @returns {Promise<number>} Number of tokens
|
| 382 |
+
*/
|
| 383 |
+
async function countSentencepieceArrayTokens(tokenizer, array) {
|
| 384 |
+
const jsonBody = array.flatMap(x => Object.values(x)).join('\n\n');
|
| 385 |
+
const result = await countSentencepieceTokens(tokenizer, jsonBody);
|
| 386 |
+
const num_tokens = result.count;
|
| 387 |
+
return num_tokens;
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
async function getTiktokenChunks(tokenizer, ids) {
|
| 391 |
+
const decoder = new TextDecoder();
|
| 392 |
+
const chunks = [];
|
| 393 |
+
|
| 394 |
+
for (let i = 0; i < ids.length; i++) {
|
| 395 |
+
const id = ids[i];
|
| 396 |
+
const chunkTextBytes = await tokenizer.decode(new Uint32Array([id]));
|
| 397 |
+
const chunkText = decoder.decode(chunkTextBytes);
|
| 398 |
+
chunks.push(chunkText);
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
return chunks;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
/**
|
| 405 |
+
* Gets the token chunks for the given token IDs using the Web tokenizer.
|
| 406 |
+
* @param {Tokenizer} tokenizer Web tokenizer instance
|
| 407 |
+
* @param {number[]} ids Token IDs
|
| 408 |
+
* @returns {string[]} Token chunks
|
| 409 |
+
*/
|
| 410 |
+
function getWebTokenizersChunks(tokenizer, ids) {
|
| 411 |
+
const chunks = [];
|
| 412 |
+
|
| 413 |
+
for (let i = 0, lastProcessed = 0; i < ids.length; i++) {
|
| 414 |
+
const chunkIds = ids.slice(lastProcessed, i + 1);
|
| 415 |
+
const chunkText = tokenizer.decode(new Int32Array(chunkIds));
|
| 416 |
+
if (chunkText === '�') {
|
| 417 |
+
continue;
|
| 418 |
+
}
|
| 419 |
+
chunks.push(chunkText);
|
| 420 |
+
lastProcessed = i + 1;
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
return chunks;
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
/**
|
| 427 |
+
* Gets the tokenizer model by the model name.
|
| 428 |
+
* @param {string} requestModel Models to use for tokenization
|
| 429 |
+
* @returns {string} Tokenizer model to use
|
| 430 |
+
*/
|
| 431 |
+
export function getTokenizerModel(requestModel) {
|
| 432 |
+
if (requestModel === 'o1' || requestModel.includes('o1-preview') || requestModel.includes('o1-mini') || requestModel.includes('o3-mini')) {
|
| 433 |
+
return 'o1';
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
if (requestModel.includes('gpt-5') || requestModel.includes('o3') || requestModel.includes('o4-mini')) {
|
| 437 |
+
return 'o1';
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
if (requestModel.includes('gpt-4o') || requestModel.includes('chatgpt-4o-latest')) {
|
| 441 |
+
return 'gpt-4o';
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
if (requestModel.includes('gpt-4.1') || requestModel.includes('gpt-4.5')) {
|
| 445 |
+
return 'gpt-4o';
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
if (requestModel.includes('gpt-4-32k')) {
|
| 449 |
+
return 'gpt-4-32k';
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
if (requestModel.includes('gpt-4')) {
|
| 453 |
+
return 'gpt-4';
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
if (requestModel.includes('gpt-3.5-turbo-0301')) {
|
| 457 |
+
return 'gpt-3.5-turbo-0301';
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
if (requestModel.includes('gpt-3.5-turbo')) {
|
| 461 |
+
return 'gpt-3.5-turbo';
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
if (TEXT_COMPLETION_MODELS.includes(requestModel)) {
|
| 465 |
+
return requestModel;
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
if (requestModel.includes('claude')) {
|
| 469 |
+
return 'claude';
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
if (requestModel.includes('llama3') || requestModel.includes('llama-3')) {
|
| 473 |
+
return 'llama3';
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
if (requestModel.includes('llama')) {
|
| 477 |
+
return 'llama';
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
if (requestModel.includes('mistral')) {
|
| 481 |
+
return 'mistral';
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
if (requestModel.includes('yi')) {
|
| 485 |
+
return 'yi';
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
if (requestModel.includes('deepseek')) {
|
| 489 |
+
return 'deepseek';
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
if (requestModel.includes('gemma') || requestModel.includes('gemini') || requestModel.includes('learnlm')) {
|
| 493 |
+
return 'gemma';
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
if (requestModel.includes('jamba')) {
|
| 497 |
+
return 'jamba';
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
if (requestModel.includes('qwen2')) {
|
| 501 |
+
return 'qwen2';
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
if (requestModel.includes('command-r')) {
|
| 505 |
+
return 'command-r';
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
if (requestModel.includes('command-a')) {
|
| 509 |
+
return 'command-a';
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
if (requestModel.includes('nemo')) {
|
| 513 |
+
return 'nemo';
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
// default
|
| 517 |
+
return 'gpt-3.5-turbo';
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
export function getTiktokenTokenizer(model) {
|
| 521 |
+
if (tokenizersCache[model]) {
|
| 522 |
+
return tokenizersCache[model];
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
const tokenizer = tiktoken.encoding_for_model(model);
|
| 526 |
+
console.info('Instantiated the tokenizer for', model);
|
| 527 |
+
tokenizersCache[model] = tokenizer;
|
| 528 |
+
return tokenizer;
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
/**
|
| 532 |
+
* Counts the tokens for the given messages using the WebTokenizer and Claude prompt conversion.
|
| 533 |
+
* @param {Tokenizer} tokenizer Web tokenizer
|
| 534 |
+
* @param {object[]} messages Array of messages
|
| 535 |
+
* @returns {number} Number of tokens
|
| 536 |
+
*/
|
| 537 |
+
export function countWebTokenizerTokens(tokenizer, messages) {
|
| 538 |
+
// Should be fine if we use the old conversion method instead of the messages API one i think?
|
| 539 |
+
const convertedPrompt = convertClaudePrompt(messages, false, '', false, false, '', false);
|
| 540 |
+
|
| 541 |
+
// Fallback to strlen estimation
|
| 542 |
+
if (!tokenizer) {
|
| 543 |
+
return Math.ceil(convertedPrompt.length / CHARS_PER_TOKEN);
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
const count = tokenizer.encode(convertedPrompt).length;
|
| 547 |
+
return count;
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
/**
|
| 551 |
+
* Creates an API handler for encoding Sentencepiece tokens.
|
| 552 |
+
* @param {SentencePieceTokenizer} tokenizer Sentencepiece tokenizer
|
| 553 |
+
* @returns {TokenizationHandler} Handler function
|
| 554 |
+
*/
|
| 555 |
+
function createSentencepieceEncodingHandler(tokenizer) {
|
| 556 |
+
/**
|
| 557 |
+
* Request handler for encoding Sentencepiece tokens.
|
| 558 |
+
* @param {import('express').Request} request
|
| 559 |
+
* @param {import('express').Response} response
|
| 560 |
+
*/
|
| 561 |
+
return async function (request, response) {
|
| 562 |
+
try {
|
| 563 |
+
if (!request.body) {
|
| 564 |
+
return response.sendStatus(400);
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
const text = request.body.text || '';
|
| 568 |
+
const instance = await tokenizer?.get();
|
| 569 |
+
const { ids, count } = await countSentencepieceTokens(tokenizer, text);
|
| 570 |
+
const chunks = instance?.encodePieces(text);
|
| 571 |
+
return response.send({ ids, count, chunks });
|
| 572 |
+
} catch (error) {
|
| 573 |
+
console.error(error);
|
| 574 |
+
return response.send({ ids: [], count: 0, chunks: [] });
|
| 575 |
+
}
|
| 576 |
+
};
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
/**
|
| 580 |
+
* Creates an API handler for decoding Sentencepiece tokens.
|
| 581 |
+
* @param {SentencePieceTokenizer} tokenizer Sentencepiece tokenizer
|
| 582 |
+
* @returns {TokenizationHandler} Handler function
|
| 583 |
+
*/
|
| 584 |
+
function createSentencepieceDecodingHandler(tokenizer) {
|
| 585 |
+
/**
|
| 586 |
+
* Request handler for decoding Sentencepiece tokens.
|
| 587 |
+
* @param {import('express').Request} request
|
| 588 |
+
* @param {import('express').Response} response
|
| 589 |
+
*/
|
| 590 |
+
return async function (request, response) {
|
| 591 |
+
try {
|
| 592 |
+
if (!request.body) {
|
| 593 |
+
return response.sendStatus(400);
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
const ids = request.body.ids || [];
|
| 597 |
+
const instance = await tokenizer?.get();
|
| 598 |
+
if (!instance) throw new Error('Failed to load the Sentencepiece tokenizer');
|
| 599 |
+
const ops = ids.map(id => instance.decodeIds([id]));
|
| 600 |
+
const chunks = await Promise.all(ops);
|
| 601 |
+
const text = chunks.join('');
|
| 602 |
+
return response.send({ text, chunks });
|
| 603 |
+
} catch (error) {
|
| 604 |
+
console.error(error);
|
| 605 |
+
return response.send({ text: '', chunks: [] });
|
| 606 |
+
}
|
| 607 |
+
};
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
/**
|
| 611 |
+
* Creates an API handler for encoding Tiktoken tokens.
|
| 612 |
+
* @param {string} modelId Tiktoken model ID
|
| 613 |
+
* @returns {TokenizationHandler} Handler function
|
| 614 |
+
*/
|
| 615 |
+
function createTiktokenEncodingHandler(modelId) {
|
| 616 |
+
/**
|
| 617 |
+
* Request handler for encoding Tiktoken tokens.
|
| 618 |
+
* @param {import('express').Request} request
|
| 619 |
+
* @param {import('express').Response} response
|
| 620 |
+
*/
|
| 621 |
+
return async function (request, response) {
|
| 622 |
+
try {
|
| 623 |
+
if (!request.body) {
|
| 624 |
+
return response.sendStatus(400);
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
const text = request.body.text || '';
|
| 628 |
+
const tokenizer = getTiktokenTokenizer(modelId);
|
| 629 |
+
const tokens = Object.values(tokenizer.encode(text));
|
| 630 |
+
const chunks = await getTiktokenChunks(tokenizer, tokens);
|
| 631 |
+
return response.send({ ids: tokens, count: tokens.length, chunks });
|
| 632 |
+
} catch (error) {
|
| 633 |
+
console.error(error);
|
| 634 |
+
return response.send({ ids: [], count: 0, chunks: [] });
|
| 635 |
+
}
|
| 636 |
+
};
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
/**
|
| 640 |
+
* Creates an API handler for decoding Tiktoken tokens.
|
| 641 |
+
* @param {string} modelId Tiktoken model ID
|
| 642 |
+
* @returns {TokenizationHandler} Handler function
|
| 643 |
+
*/
|
| 644 |
+
function createTiktokenDecodingHandler(modelId) {
|
| 645 |
+
/**
|
| 646 |
+
* Request handler for decoding Tiktoken tokens.
|
| 647 |
+
* @param {import('express').Request} request
|
| 648 |
+
* @param {import('express').Response} response
|
| 649 |
+
*/
|
| 650 |
+
return async function (request, response) {
|
| 651 |
+
try {
|
| 652 |
+
if (!request.body) {
|
| 653 |
+
return response.sendStatus(400);
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
const ids = request.body.ids || [];
|
| 657 |
+
const tokenizer = getTiktokenTokenizer(modelId);
|
| 658 |
+
const textBytes = tokenizer.decode(new Uint32Array(ids));
|
| 659 |
+
const text = new TextDecoder().decode(textBytes);
|
| 660 |
+
return response.send({ text });
|
| 661 |
+
} catch (error) {
|
| 662 |
+
console.error(error);
|
| 663 |
+
return response.send({ text: '' });
|
| 664 |
+
}
|
| 665 |
+
};
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
/**
|
| 669 |
+
* Creates an API handler for encoding WebTokenizer tokens.
|
| 670 |
+
* @param {WebTokenizer} tokenizer WebTokenizer instance
|
| 671 |
+
* @returns {TokenizationHandler} Handler function
|
| 672 |
+
*/
|
| 673 |
+
function createWebTokenizerEncodingHandler(tokenizer) {
|
| 674 |
+
/**
|
| 675 |
+
* Request handler for encoding WebTokenizer tokens.
|
| 676 |
+
* @param {import('express').Request} request
|
| 677 |
+
* @param {import('express').Response} response
|
| 678 |
+
*/
|
| 679 |
+
return async function (request, response) {
|
| 680 |
+
try {
|
| 681 |
+
if (!request.body) {
|
| 682 |
+
return response.sendStatus(400);
|
| 683 |
+
}
|
| 684 |
+
|
| 685 |
+
const text = request.body.text || '';
|
| 686 |
+
const instance = await tokenizer?.get();
|
| 687 |
+
if (!instance) throw new Error('Failed to load the Web tokenizer');
|
| 688 |
+
const tokens = Array.from(instance.encode(text));
|
| 689 |
+
const chunks = getWebTokenizersChunks(instance, tokens);
|
| 690 |
+
return response.send({ ids: tokens, count: tokens.length, chunks });
|
| 691 |
+
} catch (error) {
|
| 692 |
+
console.error(error);
|
| 693 |
+
return response.send({ ids: [], count: 0, chunks: [] });
|
| 694 |
+
}
|
| 695 |
+
};
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
/**
|
| 699 |
+
* Creates an API handler for decoding WebTokenizer tokens.
|
| 700 |
+
* @param {WebTokenizer} tokenizer WebTokenizer instance
|
| 701 |
+
* @returns {TokenizationHandler} Handler function
|
| 702 |
+
*/
|
| 703 |
+
function createWebTokenizerDecodingHandler(tokenizer) {
|
| 704 |
+
/**
|
| 705 |
+
* Request handler for decoding WebTokenizer tokens.
|
| 706 |
+
* @param {import('express').Request} request
|
| 707 |
+
* @param {import('express').Response} response
|
| 708 |
+
* @returns {Promise<any>}
|
| 709 |
+
*/
|
| 710 |
+
return async function (request, response) {
|
| 711 |
+
try {
|
| 712 |
+
if (!request.body) {
|
| 713 |
+
return response.sendStatus(400);
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
const ids = request.body.ids || [];
|
| 717 |
+
const instance = await tokenizer?.get();
|
| 718 |
+
if (!instance) throw new Error('Failed to load the Web tokenizer');
|
| 719 |
+
const chunks = getWebTokenizersChunks(instance, ids);
|
| 720 |
+
const text = instance.decode(new Int32Array(ids));
|
| 721 |
+
return response.send({ text, chunks });
|
| 722 |
+
} catch (error) {
|
| 723 |
+
console.error(error);
|
| 724 |
+
return response.send({ text: '', chunks: [] });
|
| 725 |
+
}
|
| 726 |
+
};
|
| 727 |
+
}
|
| 728 |
+
|
| 729 |
+
export const router = express.Router();
|
| 730 |
+
|
| 731 |
+
router.post('/llama/encode', createSentencepieceEncodingHandler(spp_llama));
|
| 732 |
+
router.post('/nerdstash/encode', createSentencepieceEncodingHandler(spp_nerd));
|
| 733 |
+
router.post('/nerdstash_v2/encode', createSentencepieceEncodingHandler(spp_nerd_v2));
|
| 734 |
+
router.post('/mistral/encode', createSentencepieceEncodingHandler(spp_mistral));
|
| 735 |
+
router.post('/yi/encode', createSentencepieceEncodingHandler(spp_yi));
|
| 736 |
+
router.post('/gemma/encode', createSentencepieceEncodingHandler(spp_gemma));
|
| 737 |
+
router.post('/jamba/encode', createSentencepieceEncodingHandler(spp_jamba));
|
| 738 |
+
router.post('/gpt2/encode', createTiktokenEncodingHandler('gpt2'));
|
| 739 |
+
router.post('/claude/encode', createWebTokenizerEncodingHandler(claude_tokenizer));
|
| 740 |
+
router.post('/llama3/encode', createWebTokenizerEncodingHandler(llama3_tokenizer));
|
| 741 |
+
router.post('/qwen2/encode', createWebTokenizerEncodingHandler(qwen2Tokenizer));
|
| 742 |
+
router.post('/command-r/encode', createWebTokenizerEncodingHandler(commandRTokenizer));
|
| 743 |
+
router.post('/command-a/encode', createWebTokenizerEncodingHandler(commandATokenizer));
|
| 744 |
+
router.post('/nemo/encode', createWebTokenizerEncodingHandler(nemoTokenizer));
|
| 745 |
+
router.post('/deepseek/encode', createWebTokenizerEncodingHandler(deepseekTokenizer));
|
| 746 |
+
router.post('/llama/decode', createSentencepieceDecodingHandler(spp_llama));
|
| 747 |
+
router.post('/nerdstash/decode', createSentencepieceDecodingHandler(spp_nerd));
|
| 748 |
+
router.post('/nerdstash_v2/decode', createSentencepieceDecodingHandler(spp_nerd_v2));
|
| 749 |
+
router.post('/mistral/decode', createSentencepieceDecodingHandler(spp_mistral));
|
| 750 |
+
router.post('/yi/decode', createSentencepieceDecodingHandler(spp_yi));
|
| 751 |
+
router.post('/gemma/decode', createSentencepieceDecodingHandler(spp_gemma));
|
| 752 |
+
router.post('/jamba/decode', createSentencepieceDecodingHandler(spp_jamba));
|
| 753 |
+
router.post('/gpt2/decode', createTiktokenDecodingHandler('gpt2'));
|
| 754 |
+
router.post('/claude/decode', createWebTokenizerDecodingHandler(claude_tokenizer));
|
| 755 |
+
router.post('/llama3/decode', createWebTokenizerDecodingHandler(llama3_tokenizer));
|
| 756 |
+
router.post('/qwen2/decode', createWebTokenizerDecodingHandler(qwen2Tokenizer));
|
| 757 |
+
router.post('/command-r/decode', createWebTokenizerDecodingHandler(commandRTokenizer));
|
| 758 |
+
router.post('/command-a/decode', createWebTokenizerDecodingHandler(commandATokenizer));
|
| 759 |
+
router.post('/nemo/decode', createWebTokenizerDecodingHandler(nemoTokenizer));
|
| 760 |
+
router.post('/deepseek/decode', createWebTokenizerDecodingHandler(deepseekTokenizer));
|
| 761 |
+
|
| 762 |
+
router.post('/openai/encode', async function (req, res) {
|
| 763 |
+
try {
|
| 764 |
+
const queryModel = String(req.query.model || '');
|
| 765 |
+
|
| 766 |
+
if (queryModel.includes('llama3') || queryModel.includes('llama-3')) {
|
| 767 |
+
const handler = createWebTokenizerEncodingHandler(llama3_tokenizer);
|
| 768 |
+
return handler(req, res);
|
| 769 |
+
}
|
| 770 |
+
|
| 771 |
+
if (queryModel.includes('llama')) {
|
| 772 |
+
const handler = createSentencepieceEncodingHandler(spp_llama);
|
| 773 |
+
return handler(req, res);
|
| 774 |
+
}
|
| 775 |
+
|
| 776 |
+
if (queryModel.includes('mistral')) {
|
| 777 |
+
const handler = createSentencepieceEncodingHandler(spp_mistral);
|
| 778 |
+
return handler(req, res);
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
if (queryModel.includes('yi')) {
|
| 782 |
+
const handler = createSentencepieceEncodingHandler(spp_yi);
|
| 783 |
+
return handler(req, res);
|
| 784 |
+
}
|
| 785 |
+
|
| 786 |
+
if (queryModel.includes('claude')) {
|
| 787 |
+
const handler = createWebTokenizerEncodingHandler(claude_tokenizer);
|
| 788 |
+
return handler(req, res);
|
| 789 |
+
}
|
| 790 |
+
|
| 791 |
+
if (queryModel.includes('gemma') || queryModel.includes('gemini')) {
|
| 792 |
+
const handler = createSentencepieceEncodingHandler(spp_gemma);
|
| 793 |
+
return handler(req, res);
|
| 794 |
+
}
|
| 795 |
+
|
| 796 |
+
if (queryModel.includes('jamba')) {
|
| 797 |
+
const handler = createSentencepieceEncodingHandler(spp_jamba);
|
| 798 |
+
return handler(req, res);
|
| 799 |
+
}
|
| 800 |
+
|
| 801 |
+
if (queryModel.includes('qwen2')) {
|
| 802 |
+
const handler = createWebTokenizerEncodingHandler(qwen2Tokenizer);
|
| 803 |
+
return handler(req, res);
|
| 804 |
+
}
|
| 805 |
+
|
| 806 |
+
if (queryModel.includes('command-r')) {
|
| 807 |
+
const handler = createWebTokenizerEncodingHandler(commandRTokenizer);
|
| 808 |
+
return handler(req, res);
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
if (queryModel.includes('command-a')) {
|
| 812 |
+
const handler = createWebTokenizerEncodingHandler(commandATokenizer);
|
| 813 |
+
return handler(req, res);
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
if (queryModel.includes('nemo')) {
|
| 817 |
+
const handler = createWebTokenizerEncodingHandler(nemoTokenizer);
|
| 818 |
+
return handler(req, res);
|
| 819 |
+
}
|
| 820 |
+
|
| 821 |
+
if (queryModel.includes('deepseek')) {
|
| 822 |
+
const handler = createWebTokenizerEncodingHandler(deepseekTokenizer);
|
| 823 |
+
return handler(req, res);
|
| 824 |
+
}
|
| 825 |
+
|
| 826 |
+
const model = getTokenizerModel(queryModel);
|
| 827 |
+
const handler = createTiktokenEncodingHandler(model);
|
| 828 |
+
return handler(req, res);
|
| 829 |
+
} catch (error) {
|
| 830 |
+
console.error(error);
|
| 831 |
+
return res.send({ ids: [], count: 0, chunks: [] });
|
| 832 |
+
}
|
| 833 |
+
});
|
| 834 |
+
|
| 835 |
+
router.post('/openai/decode', async function (req, res) {
|
| 836 |
+
try {
|
| 837 |
+
const queryModel = String(req.query.model || '');
|
| 838 |
+
|
| 839 |
+
if (queryModel.includes('llama3') || queryModel.includes('llama-3')) {
|
| 840 |
+
const handler = createWebTokenizerDecodingHandler(llama3_tokenizer);
|
| 841 |
+
return handler(req, res);
|
| 842 |
+
}
|
| 843 |
+
|
| 844 |
+
if (queryModel.includes('llama')) {
|
| 845 |
+
const handler = createSentencepieceDecodingHandler(spp_llama);
|
| 846 |
+
return handler(req, res);
|
| 847 |
+
}
|
| 848 |
+
|
| 849 |
+
if (queryModel.includes('mistral')) {
|
| 850 |
+
const handler = createSentencepieceDecodingHandler(spp_mistral);
|
| 851 |
+
return handler(req, res);
|
| 852 |
+
}
|
| 853 |
+
|
| 854 |
+
if (queryModel.includes('yi')) {
|
| 855 |
+
const handler = createSentencepieceDecodingHandler(spp_yi);
|
| 856 |
+
return handler(req, res);
|
| 857 |
+
}
|
| 858 |
+
|
| 859 |
+
if (queryModel.includes('claude')) {
|
| 860 |
+
const handler = createWebTokenizerDecodingHandler(claude_tokenizer);
|
| 861 |
+
return handler(req, res);
|
| 862 |
+
}
|
| 863 |
+
|
| 864 |
+
if (queryModel.includes('gemma') || queryModel.includes('gemini')) {
|
| 865 |
+
const handler = createSentencepieceDecodingHandler(spp_gemma);
|
| 866 |
+
return handler(req, res);
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
+
if (queryModel.includes('jamba')) {
|
| 870 |
+
const handler = createSentencepieceDecodingHandler(spp_jamba);
|
| 871 |
+
return handler(req, res);
|
| 872 |
+
}
|
| 873 |
+
|
| 874 |
+
if (queryModel.includes('qwen2')) {
|
| 875 |
+
const handler = createWebTokenizerDecodingHandler(qwen2Tokenizer);
|
| 876 |
+
return handler(req, res);
|
| 877 |
+
}
|
| 878 |
+
|
| 879 |
+
if (queryModel.includes('command-r')) {
|
| 880 |
+
const handler = createWebTokenizerDecodingHandler(commandRTokenizer);
|
| 881 |
+
return handler(req, res);
|
| 882 |
+
}
|
| 883 |
+
|
| 884 |
+
if (queryModel.includes('command-a')) {
|
| 885 |
+
const handler = createWebTokenizerDecodingHandler(commandATokenizer);
|
| 886 |
+
return handler(req, res);
|
| 887 |
+
}
|
| 888 |
+
|
| 889 |
+
if (queryModel.includes('nemo')) {
|
| 890 |
+
const handler = createWebTokenizerDecodingHandler(nemoTokenizer);
|
| 891 |
+
return handler(req, res);
|
| 892 |
+
}
|
| 893 |
+
|
| 894 |
+
if (queryModel.includes('deepseek')) {
|
| 895 |
+
const handler = createWebTokenizerDecodingHandler(deepseekTokenizer);
|
| 896 |
+
return handler(req, res);
|
| 897 |
+
}
|
| 898 |
+
|
| 899 |
+
const model = getTokenizerModel(queryModel);
|
| 900 |
+
const handler = createTiktokenDecodingHandler(model);
|
| 901 |
+
return handler(req, res);
|
| 902 |
+
} catch (error) {
|
| 903 |
+
console.error(error);
|
| 904 |
+
return res.send({ text: '' });
|
| 905 |
+
}
|
| 906 |
+
});
|
| 907 |
+
|
| 908 |
+
router.post('/openai/count', async function (req, res) {
|
| 909 |
+
try {
|
| 910 |
+
if (!req.body) return res.sendStatus(400);
|
| 911 |
+
|
| 912 |
+
let num_tokens = 0;
|
| 913 |
+
const queryModel = String(req.query.model || '');
|
| 914 |
+
const model = getTokenizerModel(queryModel);
|
| 915 |
+
|
| 916 |
+
if (model === 'claude') {
|
| 917 |
+
const instance = await claude_tokenizer.get();
|
| 918 |
+
if (!instance) throw new Error('Failed to load the Claude tokenizer');
|
| 919 |
+
num_tokens = countWebTokenizerTokens(instance, req.body);
|
| 920 |
+
return res.send({ 'token_count': num_tokens });
|
| 921 |
+
}
|
| 922 |
+
|
| 923 |
+
if (model === 'llama3' || model === 'llama-3') {
|
| 924 |
+
const instance = await llama3_tokenizer.get();
|
| 925 |
+
if (!instance) throw new Error('Failed to load the Llama3 tokenizer');
|
| 926 |
+
num_tokens = countWebTokenizerTokens(instance, req.body);
|
| 927 |
+
return res.send({ 'token_count': num_tokens });
|
| 928 |
+
}
|
| 929 |
+
|
| 930 |
+
if (model === 'llama') {
|
| 931 |
+
num_tokens = await countSentencepieceArrayTokens(spp_llama, req.body);
|
| 932 |
+
return res.send({ 'token_count': num_tokens });
|
| 933 |
+
}
|
| 934 |
+
|
| 935 |
+
if (model === 'mistral') {
|
| 936 |
+
num_tokens = await countSentencepieceArrayTokens(spp_mistral, req.body);
|
| 937 |
+
return res.send({ 'token_count': num_tokens });
|
| 938 |
+
}
|
| 939 |
+
|
| 940 |
+
if (model === 'yi') {
|
| 941 |
+
num_tokens = await countSentencepieceArrayTokens(spp_yi, req.body);
|
| 942 |
+
return res.send({ 'token_count': num_tokens });
|
| 943 |
+
}
|
| 944 |
+
|
| 945 |
+
if (model === 'gemma' || model === 'gemini') {
|
| 946 |
+
num_tokens = await countSentencepieceArrayTokens(spp_gemma, req.body);
|
| 947 |
+
return res.send({ 'token_count': num_tokens });
|
| 948 |
+
}
|
| 949 |
+
|
| 950 |
+
if (model === 'jamba') {
|
| 951 |
+
num_tokens = await countSentencepieceArrayTokens(spp_jamba, req.body);
|
| 952 |
+
return res.send({ 'token_count': num_tokens });
|
| 953 |
+
}
|
| 954 |
+
|
| 955 |
+
if (model === 'qwen2') {
|
| 956 |
+
const instance = await qwen2Tokenizer.get();
|
| 957 |
+
if (!instance) throw new Error('Failed to load the Qwen2 tokenizer');
|
| 958 |
+
num_tokens = countWebTokenizerTokens(instance, req.body);
|
| 959 |
+
return res.send({ 'token_count': num_tokens });
|
| 960 |
+
}
|
| 961 |
+
|
| 962 |
+
if (model === 'command-r') {
|
| 963 |
+
const instance = await commandRTokenizer.get();
|
| 964 |
+
if (!instance) throw new Error('Failed to load the Command-R tokenizer');
|
| 965 |
+
num_tokens = countWebTokenizerTokens(instance, req.body);
|
| 966 |
+
return res.send({ 'token_count': num_tokens });
|
| 967 |
+
}
|
| 968 |
+
|
| 969 |
+
if (model === 'command-a') {
|
| 970 |
+
const instance = await commandATokenizer.get();
|
| 971 |
+
if (!instance) throw new Error('Failed to load the Command-A tokenizer');
|
| 972 |
+
num_tokens = countWebTokenizerTokens(instance, req.body);
|
| 973 |
+
return res.send({ 'token_count': num_tokens });
|
| 974 |
+
}
|
| 975 |
+
|
| 976 |
+
if (model === 'nemo') {
|
| 977 |
+
const instance = await nemoTokenizer.get();
|
| 978 |
+
if (!instance) throw new Error('Failed to load the Nemo tokenizer');
|
| 979 |
+
num_tokens = countWebTokenizerTokens(instance, req.body);
|
| 980 |
+
return res.send({ 'token_count': num_tokens });
|
| 981 |
+
}
|
| 982 |
+
|
| 983 |
+
if (model === 'deepseek') {
|
| 984 |
+
const instance = await deepseekTokenizer.get();
|
| 985 |
+
if (!instance) throw new Error('Failed to load the DeepSeek tokenizer');
|
| 986 |
+
num_tokens = countWebTokenizerTokens(instance, req.body);
|
| 987 |
+
return res.send({ 'token_count': num_tokens });
|
| 988 |
+
}
|
| 989 |
+
|
| 990 |
+
const tokensPerName = queryModel.includes('gpt-3.5-turbo-0301') ? -1 : 1;
|
| 991 |
+
const tokensPerMessage = queryModel.includes('gpt-3.5-turbo-0301') ? 4 : 3;
|
| 992 |
+
const tokensPadding = 3;
|
| 993 |
+
|
| 994 |
+
const tokenizer = getTiktokenTokenizer(model);
|
| 995 |
+
|
| 996 |
+
for (const msg of req.body) {
|
| 997 |
+
try {
|
| 998 |
+
num_tokens += tokensPerMessage;
|
| 999 |
+
for (const [key, value] of Object.entries(msg)) {
|
| 1000 |
+
num_tokens += tokenizer.encode(value).length;
|
| 1001 |
+
if (key == 'name') {
|
| 1002 |
+
num_tokens += tokensPerName;
|
| 1003 |
+
}
|
| 1004 |
+
}
|
| 1005 |
+
} catch {
|
| 1006 |
+
console.warn('Error tokenizing message:', msg);
|
| 1007 |
+
}
|
| 1008 |
+
}
|
| 1009 |
+
num_tokens += tokensPadding;
|
| 1010 |
+
|
| 1011 |
+
// NB: Since 2023-10-14, the GPT-3.5 Turbo 0301 model shoves in 7-9 extra tokens to every message.
|
| 1012 |
+
// More details: https://community.openai.com/t/gpt-3-5-turbo-0301-showing-different-behavior-suddenly/431326/14
|
| 1013 |
+
if (queryModel.includes('gpt-3.5-turbo-0301')) {
|
| 1014 |
+
num_tokens += 9;
|
| 1015 |
+
}
|
| 1016 |
+
|
| 1017 |
+
// not needed for cached tokenizers
|
| 1018 |
+
//tokenizer.free();
|
| 1019 |
+
|
| 1020 |
+
res.send({ 'token_count': num_tokens });
|
| 1021 |
+
} catch (error) {
|
| 1022 |
+
console.error('An error counting tokens, using fallback estimation method', error);
|
| 1023 |
+
const jsonBody = JSON.stringify(req.body);
|
| 1024 |
+
const num_tokens = Math.ceil(jsonBody.length / CHARS_PER_TOKEN);
|
| 1025 |
+
res.send({ 'token_count': num_tokens });
|
| 1026 |
+
}
|
| 1027 |
+
});
|
| 1028 |
+
|
| 1029 |
+
router.post('/remote/kobold/count', async function (request, response) {
|
| 1030 |
+
if (!request.body) {
|
| 1031 |
+
return response.sendStatus(400);
|
| 1032 |
+
}
|
| 1033 |
+
const text = String(request.body.text) || '';
|
| 1034 |
+
const baseUrl = String(request.body.url);
|
| 1035 |
+
|
| 1036 |
+
try {
|
| 1037 |
+
const args = {
|
| 1038 |
+
method: 'POST',
|
| 1039 |
+
body: JSON.stringify({ 'prompt': text }),
|
| 1040 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1041 |
+
};
|
| 1042 |
+
|
| 1043 |
+
let url = String(baseUrl).replace(/\/$/, '');
|
| 1044 |
+
url += '/extra/tokencount';
|
| 1045 |
+
|
| 1046 |
+
const result = await fetch(url, args);
|
| 1047 |
+
|
| 1048 |
+
if (!result.ok) {
|
| 1049 |
+
console.warn(`API returned error: ${result.status} ${result.statusText}`);
|
| 1050 |
+
return response.send({ error: true });
|
| 1051 |
+
}
|
| 1052 |
+
|
| 1053 |
+
/** @type {any} */
|
| 1054 |
+
const data = await result.json();
|
| 1055 |
+
const count = data['value'];
|
| 1056 |
+
const ids = data['ids'] ?? [];
|
| 1057 |
+
return response.send({ count, ids });
|
| 1058 |
+
} catch (error) {
|
| 1059 |
+
console.error(error);
|
| 1060 |
+
return response.send({ error: true });
|
| 1061 |
+
}
|
| 1062 |
+
});
|
| 1063 |
+
|
| 1064 |
+
router.post('/remote/textgenerationwebui/encode', async function (request, response) {
|
| 1065 |
+
if (!request.body) {
|
| 1066 |
+
return response.sendStatus(400);
|
| 1067 |
+
}
|
| 1068 |
+
const text = String(request.body.text) || '';
|
| 1069 |
+
const baseUrl = String(request.body.url);
|
| 1070 |
+
const vllmModel = String(request.body.vllm_model) || '';
|
| 1071 |
+
const aphroditeModel = String(request.body.aphrodite_model) || '';
|
| 1072 |
+
|
| 1073 |
+
try {
|
| 1074 |
+
const args = {
|
| 1075 |
+
method: 'POST',
|
| 1076 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1077 |
+
};
|
| 1078 |
+
|
| 1079 |
+
setAdditionalHeaders(request, args, baseUrl);
|
| 1080 |
+
|
| 1081 |
+
// Convert to string + remove trailing slash + /v1 suffix
|
| 1082 |
+
let url = String(baseUrl).replace(/\/$/, '').replace(/\/v1$/, '');
|
| 1083 |
+
|
| 1084 |
+
switch (request.body.api_type) {
|
| 1085 |
+
case TEXTGEN_TYPES.TABBY:
|
| 1086 |
+
url += '/v1/token/encode';
|
| 1087 |
+
args.body = JSON.stringify({ 'text': text });
|
| 1088 |
+
break;
|
| 1089 |
+
case TEXTGEN_TYPES.KOBOLDCPP:
|
| 1090 |
+
url += '/api/extra/tokencount';
|
| 1091 |
+
args.body = JSON.stringify({ 'prompt': text });
|
| 1092 |
+
break;
|
| 1093 |
+
case TEXTGEN_TYPES.LLAMACPP:
|
| 1094 |
+
url += '/tokenize';
|
| 1095 |
+
args.body = JSON.stringify({ 'content': text });
|
| 1096 |
+
break;
|
| 1097 |
+
case TEXTGEN_TYPES.VLLM:
|
| 1098 |
+
url += '/tokenize';
|
| 1099 |
+
args.body = JSON.stringify({ 'model': vllmModel, 'prompt': text });
|
| 1100 |
+
break;
|
| 1101 |
+
case TEXTGEN_TYPES.APHRODITE:
|
| 1102 |
+
url += '/v1/tokenize';
|
| 1103 |
+
args.body = JSON.stringify({ 'model': aphroditeModel, 'prompt': text });
|
| 1104 |
+
break;
|
| 1105 |
+
default:
|
| 1106 |
+
url += '/v1/internal/encode';
|
| 1107 |
+
args.body = JSON.stringify({ 'text': text });
|
| 1108 |
+
break;
|
| 1109 |
+
}
|
| 1110 |
+
|
| 1111 |
+
const result = await fetch(url, args);
|
| 1112 |
+
|
| 1113 |
+
if (!result.ok) {
|
| 1114 |
+
console.warn(`API returned error: ${result.status} ${result.statusText}`);
|
| 1115 |
+
return response.send({ error: true });
|
| 1116 |
+
}
|
| 1117 |
+
|
| 1118 |
+
/** @type {any} */
|
| 1119 |
+
const data = await result.json();
|
| 1120 |
+
const count = (data?.length ?? data?.count ?? data?.value ?? data?.tokens?.length);
|
| 1121 |
+
const ids = (data?.tokens ?? data?.ids ?? []);
|
| 1122 |
+
|
| 1123 |
+
return response.send({ count, ids });
|
| 1124 |
+
} catch (error) {
|
| 1125 |
+
console.error(error);
|
| 1126 |
+
return response.send({ error: true });
|
| 1127 |
+
}
|
| 1128 |
+
});
|