#!/usr/bin/env node // native node modules const child_process = require('child_process') const crypto = require('crypto'); const fs = require('fs'); const http = require("http"); const https = require('https'); const path = require('path'); const readline = require('readline'); const util = require('util'); const { Readable } = require('stream'); const { finished } = require('stream/promises'); const { TextEncoder, TextDecoder } = require('util'); // cli/fs related library imports const commandExistsSync = require('command-exists').sync; const open = require('open'); const sanitize = require('sanitize-filename'); const simpleGit = require('simple-git'); const writeFileAtomicSync = require('write-file-atomic').sync; const yargs = require('yargs/yargs'); const { hideBin } = require('yargs/helpers'); // express/server related library imports const cors = require('cors'); const doubleCsrf = require('csrf-csrf').doubleCsrf; const express = require('express'); const compression = require('compression'); const cookieParser = require('cookie-parser'); const multer = require("multer"); const responseTime = require('response-time'); // net related library imports const DeviceDetector = require("device-detector-js"); const fetch = require('node-fetch').default; const ipaddr = require('ipaddr.js'); const ipMatching = require('ip-matching'); const json5 = require('json5'); const WebSocket = require('ws'); // image processing related library imports const exif = require('piexifjs'); const encode = require('png-chunks-encode'); const extract = require('png-chunks-extract'); const jimp = require('jimp'); const mime = require('mime-types'); const PNGtext = require('png-chunk-text'); const webp = require('webp-converter'); const yauzl = require('yauzl'); // tokenizing related library imports const { SentencePieceProcessor } = require("@agnai/sentencepiece-js"); const tiktoken = require('@dqbd/tiktoken'); const { Tokenizer } = require('@agnai/web-tokenizers'); // misc/other imports const _ = require('lodash'); const { generateRequestUrl, normaliseResponse } = require('google-translate-api-browser'); // Create files before running anything else createDefaultFiles(); // local library imports const AIHorde = require("./src/horde"); const basicAuthMiddleware = require('./src/middleware/basicAuthMiddleware'); const characterCardParser = require('./src/character-card-parser.js'); const contentManager = require('./src/content-manager'); const novelai = require('./src/novelai'); const statsHelpers = require('./statsHelpers.js'); function createDefaultFiles() { const files = { settings: 'public/settings.json', bg_load: 'public/css/bg_load.css', config: 'config.conf', }; for (const file of Object.values(files)) { try { if (!fs.existsSync(file)) { const defaultFilePath = path.join('default', path.parse(file).base); fs.copyFileSync(defaultFilePath, file); console.log(`Created default file: ${file}`); } } catch (error) { console.error(`FATAL: Could not write default file: ${file}`, error); } } } const net = require("net"); // @ts-ignore work around a node v20 bug: https://github.com/nodejs/node/issues/47822#issuecomment-1564708870 if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false); const cliArguments = yargs(hideBin(process.argv)) .option('disableCsrf', { type: 'boolean', default: false, describe: 'Disables CSRF protection' }).option('ssl', { type: 'boolean', default: false, describe: 'Enables SSL' }).option('certPath', { type: 'string', default: 'certs/cert.pem', describe: 'Path to your certificate file.' }).option('keyPath', { type: 'string', default: 'certs/privkey.pem', describe: 'Path to your private key file.' }).parseSync(); // change all relative paths const directory = process['pkg'] ? path.dirname(process.execPath) : __dirname; console.log(process['pkg'] ? 'Running from binary' : 'Running from source'); process.chdir(directory); const app = express(); app.use(compression()); app.use(responseTime()); const utf8Encode = new TextEncoder(); // impoort from statsHelpers.js const config = require(path.join(process.cwd(), './config.conf')); const server_port = process.env.SILLY_TAVERN_PORT || config.port; const whitelistPath = path.join(process.cwd(), "./whitelist.txt"); let whitelist = config.whitelist; if (fs.existsSync(whitelistPath)) { try { let whitelistTxt = fs.readFileSync(whitelistPath, 'utf-8'); whitelist = whitelistTxt.split("\n").filter(ip => ip).map(ip => ip.trim()); } catch (e) { } } const whitelistMode = config.whitelistMode; const autorun = config.autorun && !cliArguments.ssl; const enableExtensions = config.enableExtensions; const listen = config.listen; const allowKeysExposure = config.allowKeysExposure; function getHordeClient() { const ai_horde = new AIHorde({ client_agent: getVersion()?.agent || 'SillyTavern:UNKNOWN:Cohee#1207', }); return ai_horde; } const API_NOVELAI = "https://api.novelai.net"; const API_OPENAI = "https://api.openai.com/v1"; const API_CLAUDE = "https://api.anthropic.com/v1"; // These should be gone and come from the frontend. But for now, they're here. let api_server = "http://0.0.0.0:5000"; let main_api = "kobold"; let characters = {}; let response_dw_bg; let color = { byNum: (mess, fgNum) => { mess = mess || ''; fgNum = fgNum === undefined ? 31 : fgNum; return '\u001b[' + fgNum + 'm' + mess + '\u001b[39m'; }, black: (mess) => color.byNum(mess, 30), red: (mess) => color.byNum(mess, 31), green: (mess) => color.byNum(mess, 32), yellow: (mess) => color.byNum(mess, 33), blue: (mess) => color.byNum(mess, 34), magenta: (mess) => color.byNum(mess, 35), cyan: (mess) => color.byNum(mess, 36), white: (mess) => color.byNum(mess, 37) }; function get_mancer_headers() { const api_key_mancer = readSecret(SECRET_KEYS.MANCER); return api_key_mancer ? { "X-API-KEY": api_key_mancer } : {}; } function getOverrideHeaders(urlHost) { const overrideHeaders = config.requestOverrides?.find((e) => e.hosts?.includes(urlHost))?.headers; if (overrideHeaders && urlHost) { return overrideHeaders; } else { return {}; } } /** * Encodes the Basic Auth header value for the given user and password. * @param {string} auth username:password * @returns {string} Basic Auth header value */ function getBasicAuthHeader(auth) { const encoded = Buffer.from(`${auth}`).toString('base64'); return `Basic ${encoded}`; } //RossAscends: Added function to format dates used in files and chat timestamps to a humanized format. //Mostly I wanted this to be for file names, but couldn't figure out exactly where the filename save code was as everything seemed to be connected. //During testing, this performs the same as previous date.now() structure. //It also does not break old characters/chats, as the code just uses whatever timestamp exists in the chat. //New chats made with characters will use this new formatting. //Useable variable is (( humanizedISO8601Datetime )) const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) const CHARS_PER_TOKEN = 3.35; let spp_llama; let spp_nerd; let spp_nerd_v2; let claude_tokenizer; async function loadSentencepieceTokenizer(modelPath) { try { const spp = new SentencePieceProcessor(); await spp.load(modelPath); return spp; } catch (error) { console.error("Sentencepiece tokenizer failed to load: " + modelPath, error); return null; } }; async function countSentencepieceTokens(spp, text) { // Fallback to strlen estimation if (!spp) { return { ids: [], count: Math.ceil(text.length / CHARS_PER_TOKEN) }; } let cleaned = text; // cleanText(text); <-- cleaning text can result in an incorrect tokenization let ids = spp.encodeIds(cleaned); return { ids, count: ids.length }; } async function loadClaudeTokenizer(modelPath) { try { const arrayBuffer = fs.readFileSync(modelPath).buffer; const instance = await Tokenizer.fromJSON(arrayBuffer); return instance; } catch (error) { console.error("Claude tokenizer failed to load: " + modelPath, error); return null; } } function countClaudeTokens(tokenizer, messages) { const convertedPrompt = convertClaudePrompt(messages, false, false); // Fallback to strlen estimation if (!tokenizer) { return Math.ceil(convertedPrompt.length / CHARS_PER_TOKEN); } const count = tokenizer.encode(convertedPrompt).length; return count; } const tokenizersCache = {}; function getTokenizerModel(requestModel) { if (requestModel.includes('claude')) { return 'claude'; } if (requestModel.includes('gpt-4-32k')) { return 'gpt-4-32k'; } if (requestModel.includes('gpt-4')) { return 'gpt-4'; } if (requestModel.includes('gpt-3.5-turbo')) { return 'gpt-3.5-turbo'; } if (requestModel.startsWith('text-') || requestModel.startsWith('code-')) { return requestModel; } // default return 'gpt-3.5-turbo'; } function getTiktokenTokenizer(model) { if (tokenizersCache[model]) { return tokenizersCache[model]; } const tokenizer = tiktoken.encoding_for_model(model); console.log('Instantiated the tokenizer for', model); tokenizersCache[model] = tokenizer; return tokenizer; } function humanizedISO8601DateTime(date) { let baseDate = typeof date === 'number' ? new Date(date) : new Date(); let humanYear = baseDate.getFullYear(); let humanMonth = (baseDate.getMonth() + 1); let humanDate = baseDate.getDate(); let humanHour = (baseDate.getHours() < 10 ? '0' : '') + baseDate.getHours(); let humanMinute = (baseDate.getMinutes() < 10 ? '0' : '') + baseDate.getMinutes(); let humanSecond = (baseDate.getSeconds() < 10 ? '0' : '') + baseDate.getSeconds(); let humanMillisecond = (baseDate.getMilliseconds() < 10 ? '0' : '') + baseDate.getMilliseconds(); let HumanizedDateTime = (humanYear + "-" + humanMonth + "-" + humanDate + " @" + humanHour + "h " + humanMinute + "m " + humanSecond + "s " + humanMillisecond + "ms"); return HumanizedDateTime; }; var charactersPath = 'public/characters/'; var chatsPath = 'public/chats/'; const UPLOADS_PATH = './uploads'; const AVATAR_WIDTH = 400; const AVATAR_HEIGHT = 600; const jsonParser = express.json({ limit: '100mb' }); const urlencodedParser = express.urlencoded({ extended: true, limit: '100mb' }); const directories = { worlds: 'public/worlds/', avatars: 'public/User Avatars', images: 'public/img/', userImages: 'public/user/images/', groups: 'public/groups/', groupChats: 'public/group chats', chats: 'public/chats/', characters: 'public/characters/', backgrounds: 'public/backgrounds', novelAI_Settings: 'public/NovelAI Settings', koboldAI_Settings: 'public/KoboldAI Settings', openAI_Settings: 'public/OpenAI Settings', textGen_Settings: 'public/TextGen Settings', thumbnails: 'thumbnails/', thumbnailsBg: 'thumbnails/bg/', thumbnailsAvatar: 'thumbnails/avatar/', themes: 'public/themes', movingUI: 'public/movingUI', extensions: 'public/scripts/extensions', instruct: 'public/instruct', context: 'public/context', backups: 'backups/', quickreplies: 'public/QuickReplies', assets: 'public/assets', }; // CSRF Protection // if (cliArguments.disableCsrf === false) { const CSRF_SECRET = crypto.randomBytes(8).toString('hex'); const COOKIES_SECRET = crypto.randomBytes(8).toString('hex'); const { generateToken, doubleCsrfProtection } = doubleCsrf({ getSecret: () => CSRF_SECRET, cookieName: "X-CSRF-Token", cookieOptions: { httpOnly: true, sameSite: "strict", secure: false }, size: 64, getTokenFromRequest: (req) => req.headers["x-csrf-token"] }); app.get("/csrf-token", (req, res) => { res.json({ "token": generateToken(res, req) }); }); app.use(cookieParser(COOKIES_SECRET)); app.use(doubleCsrfProtection); } else { console.warn("\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n"); app.get("/csrf-token", (req, res) => { res.json({ "token": 'disabled' }); }); } // CORS Settings // const CORS = cors({ origin: 'null', methods: ['OPTIONS'] }); app.use(CORS); if (listen && config.basicAuthMode) app.use(basicAuthMiddleware); // IP Whitelist // let knownIPs = new Set(); function getIpFromRequest(req) { let clientIp = req.connection.remoteAddress; let ip = ipaddr.parse(clientIp); // Check if the IP address is IPv4-mapped IPv6 address if (ip.kind() === 'ipv6' && ip instanceof ipaddr.IPv6 && ip.isIPv4MappedAddress()) { const ipv4 = ip.toIPv4Address().toString(); clientIp = ipv4; } else { clientIp = ip; clientIp = clientIp.toString(); } return clientIp; } app.use(function (req, res, next) { const clientIp = getIpFromRequest(req); if (listen && !knownIPs.has(clientIp)) { const userAgent = req.headers['user-agent']; console.log(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`)); knownIPs.add(clientIp); // Write access log const timestamp = new Date().toISOString(); const log = `${timestamp} ${clientIp} ${userAgent}\n`; fs.appendFile('access.log', log, (err) => { if (err) { console.error('Failed to write access log:', err); } }); } //clientIp = req.connection.remoteAddress.split(':').pop(); if (whitelistMode === true && !whitelist.some(x => ipMatching.matches(clientIp, ipMatching.getMatch(x)))) { console.log(color.red('Forbidden: Connection attempt from ' + clientIp + '. If you are attempting to connect, please add your IP address in whitelist or disable whitelist mode in config.conf in root of SillyTavern folder.\n')); return res.status(403).send('Forbidden: Connection attempt from ' + clientIp + '. If you are attempting to connect, please add your IP address in whitelist or disable whitelist mode in config.conf in root of SillyTavern folder.'); } next(); }); app.use(express.static(process.cwd() + "/public", {})); app.use('/backgrounds', (req, res) => { const filePath = decodeURIComponent(path.join(process.cwd(), 'public/backgrounds', req.url.replace(/%20/g, ' '))); fs.readFile(filePath, (err, data) => { if (err) { res.status(404).send('File not found'); return; } //res.contentType('image/jpeg'); res.send(data); }); }); app.use('/characters', (req, res) => { const filePath = decodeURIComponent(path.join(process.cwd(), charactersPath, req.url.replace(/%20/g, ' '))); fs.readFile(filePath, (err, data) => { if (err) { res.status(404).send('File not found'); return; } res.send(data); }); }); app.use(multer({ dest: UPLOADS_PATH, limits: { fieldSize: 10 * 1024 * 1024 } }).single("avatar")); app.get("/", function (request, response) { response.sendFile(process.cwd() + "/public/index.html"); }); app.get("/notes/*", function (request, response) { response.sendFile(process.cwd() + "/public" + request.url + ".html"); }); app.get('/deviceinfo', function (request, response) { const userAgent = request.header('user-agent'); const deviceDetector = new DeviceDetector(); const deviceInfo = deviceDetector.parse(userAgent || ""); return response.send(deviceInfo); }); app.get('/version', function (_, response) { const data = getVersion(); response.send(data); }) //**************Kobold api app.post("/generate", jsonParser, async function (request, response_generate) { if (!request.body) return response_generate.sendStatus(400); const request_prompt = request.body.prompt; const controller = new AbortController(); request.socket.removeAllListeners('close'); request.socket.on('close', async function () { if (request.body.can_abort && !response_generate.writableEnded) { try { console.log('Aborting Kobold generation...'); // send abort signal to koboldcpp const abortResponse = await fetch(`${api_server}/extra/abort`, { method: 'POST', }); if (!abortResponse.ok) { console.log('Error sending abort request to Kobold:', abortResponse.status); } } catch (error) { console.log(error); } } controller.abort(); }); let this_settings = { prompt: request_prompt, use_story: false, use_memory: false, use_authors_note: false, use_world_info: false, max_context_length: request.body.max_context_length, singleline: !!request.body.singleline, }; if (request.body.gui_settings == false) { const sampler_order = [request.body.s1, request.body.s2, request.body.s3, request.body.s4, request.body.s5, request.body.s6, request.body.s7]; this_settings = { prompt: request_prompt, use_story: false, use_memory: false, use_authors_note: false, use_world_info: false, max_context_length: request.body.max_context_length, max_length: request.body.max_length, rep_pen: request.body.rep_pen, rep_pen_range: request.body.rep_pen_range, rep_pen_slope: request.body.rep_pen_slope, temperature: request.body.temperature, tfs: request.body.tfs, top_a: request.body.top_a, top_k: request.body.top_k, top_p: request.body.top_p, typical: request.body.typical, sampler_order: sampler_order, singleline: !!request.body.singleline, use_default_badwordsids: request.body.use_default_badwordsids, mirostat: request.body.mirostat, mirostat_eta: request.body.mirostat_eta, mirostat_tau: request.body.mirostat_tau, }; if (!!request.body.stop_sequence) { this_settings['stop_sequence'] = request.body.stop_sequence; } } console.log(this_settings); const args = { body: JSON.stringify(this_settings), headers: Object.assign( { "Content-Type": "application/json" }, getOverrideHeaders((new URL(api_server))?.host) ), signal: controller.signal, }; const MAX_RETRIES = 50; const delayAmount = 2500; for (let i = 0; i < MAX_RETRIES; i++) { try { const url = request.body.streaming ? `${api_server}/extra/generate/stream` : `${api_server}/v1/generate`; const response = await fetch(url, { method: 'POST', timeout: 0, ...args }); if (request.body.streaming) { request.socket.on('close', function () { if (response.body instanceof Readable) response.body.destroy(); // Close the remote stream response_generate.end(); // End the Express response }); response.body.on('end', function () { console.log("Streaming request finished"); response_generate.end(); }); // Pipe remote SSE stream to Express response return response.body.pipe(response_generate); } else { if (!response.ok) { const errorText = await response.text(); console.log(`Kobold returned error: ${response.status} ${response.statusText} ${errorText}`); try { const errorJson = JSON.parse(errorText); const message = errorJson?.detail?.msg || errorText; return response_generate.status(400).send({ error: { message } }); } catch { return response_generate.status(400).send({ error: { message: errorText } }); } } const data = await response.json(); console.log("Endpoint response:", data); return response_generate.send(data); } } catch (error) { // response switch (error?.status) { case 403: case 503: // retry in case of temporary service issue, possibly caused by a queue failure? console.debug(`KoboldAI is busy. Retry attempt ${i + 1} of ${MAX_RETRIES}...`); await delay(delayAmount); break; default: if ('status' in error) { console.log('Status Code from Kobold:', error.status); } return response_generate.send({ error: true }); } } } console.log('Max retries exceeded. Giving up.'); return response_generate.send({ error: true }); }); //************** Text generation web UI app.post("/generate_textgenerationwebui", jsonParser, async function (request, response_generate) { if (!request.body) return response_generate.sendStatus(400); console.log(request.body); const controller = new AbortController(); let isGenerationStopped = false; request.socket.removeAllListeners('close'); request.socket.on('close', function () { isGenerationStopped = true; controller.abort(); }); if (request.header('X-Response-Streaming')) { const streamingUrlHeader = request.header('X-Streaming-URL'); if (streamingUrlHeader === undefined) return response_generate.sendStatus(400); const streamingUrlString = streamingUrlHeader.replace("localhost", "127.0.0.1"); response_generate.writeHead(200, { 'Content-Type': 'text/plain;charset=utf-8', 'Transfer-Encoding': 'chunked', 'Cache-Control': 'no-transform', }); async function* readWebsocket() { const streamingUrl = new URL(streamingUrlString); const websocket = new WebSocket(streamingUrl); websocket.on('open', async function () { console.log('WebSocket opened'); const combined_args = Object.assign( {}, request.body.use_mancer ? get_mancer_headers() : getOverrideHeaders(streamingUrl?.host), request.body ); console.log(combined_args); websocket.send(JSON.stringify(combined_args)); }); websocket.on('close', (code, buffer) => { const reason = new TextDecoder().decode(buffer) console.log("WebSocket closed (reason: %o)", reason); }); while (true) { if (isGenerationStopped) { console.error('Streaming stopped by user. Closing websocket...'); websocket.close(); return; } let rawMessage = null; try { // This lunacy is because the websocket can fail to connect AFTER we're awaiting 'message'... so 'message' never triggers. // So instead we need to look for 'error' at the same time to reject the promise. And then remove the listener if we resolve. // This is awful. // Welcome to the shenanigan shack. rawMessage = await new Promise(function (resolve, reject) { websocket.once('error', reject); websocket.once('message', (data, isBinary) => { websocket.removeListener('error', reject); resolve(data); }); }); } catch (err) { console.error("Socket error:", err); websocket.close(); yield "[SillyTavern] Streaming failed:\n" + err; return; } const message = json5.parse(rawMessage); switch (message.event) { case 'text_stream': yield message.text; break; case 'stream_end': websocket.close(); return; } } } let reply = ''; try { for await (const text of readWebsocket()) { if (typeof text !== 'string') { break; } let newText = text; if (!newText) { continue; } reply += text; response_generate.write(newText); } console.log(reply); } finally { response_generate.end(); } } else { const args = { body: JSON.stringify(request.body), headers: { "Content-Type": "application/json" }, signal: controller.signal, }; if (request.body.use_mancer) { args.headers = Object.assign(args.headers, get_mancer_headers()); } else { args.headers = Object.assign(args.headers, getOverrideHeaders((new URL(api_server))?.host)); } try { const data = await postAsync(api_server + "/v1/generate", args); console.log("Endpoint response:", data); return response_generate.send(data); } catch (error) { let retval = { error: true, status: error.status, response: error.statusText }; console.log("Endpoint error:", error); try { retval.response = await error.json(); retval.response = retval.response.result; } catch { } return response_generate.send(retval); } } }); app.post("/savechat", jsonParser, function (request, response) { try { var dir_name = String(request.body.avatar_url).replace('.png', ''); let chat_data = request.body.chat; let jsonlData = chat_data.map(JSON.stringify).join('\n'); writeFileAtomicSync(`${chatsPath + sanitize(dir_name)}/${sanitize(String(request.body.file_name))}.jsonl`, jsonlData, 'utf8'); return response.send({ result: "ok" }); } catch (error) { response.send(error); return console.log(error); } }); app.post("/getchat", jsonParser, function (request, response) { try { const dirName = String(request.body.avatar_url).replace('.png', ''); const chatDirExists = fs.existsSync(chatsPath + dirName); //if no chat dir for the character is found, make one with the character name if (!chatDirExists) { fs.mkdirSync(chatsPath + dirName); return response.send({}); } if (!request.body.file_name) { return response.send({}); } const fileName = `${chatsPath + dirName}/${sanitize(String(request.body.file_name))}.jsonl`; const chatFileExists = fs.existsSync(fileName); if (!chatFileExists) { return response.send({}); } const data = fs.readFileSync(fileName, 'utf8'); const lines = data.split('\n'); // Iterate through the array of strings and parse each line as JSON const jsonData = lines.map(tryParse).filter(x => x); return response.send(jsonData); } catch (error) { console.error(error); return response.send({}); } }); // Only called for kobold and ooba/mancer app.post("/getstatus", jsonParser, async function (request, response) { if (!request.body) return response.sendStatus(400); api_server = request.body.api_server; main_api = request.body.main_api; if (api_server.indexOf('localhost') != -1) { api_server = api_server.replace('localhost', '127.0.0.1'); } const args = { headers: { "Content-Type": "application/json" } }; if (main_api == 'textgenerationwebui' && request.body.use_mancer) { args.headers = Object.assign(args.headers, get_mancer_headers()); } else { args.headers = Object.assign(args.headers, getOverrideHeaders((new URL(api_server))?.host)); } const url = api_server + "/v1/model"; let version = ''; let koboldVersion = {}; if (main_api == "kobold") { try { version = (await fetchJSON(api_server + "/v1/info/version")).result } catch { version = '0.0.0'; } try { koboldVersion = (await fetchJSON(api_server + "/extra/version")); } catch { koboldVersion = { result: 'Kobold', version: '0.0', }; } } try { let data = await fetchJSON(url, args); if (!data || typeof data !== 'object') { data = {}; } if (data.result == "ReadOnly") { data.result = "no_connection"; } data.version = version; data.koboldVersion = koboldVersion; return response.send(data); } catch (error) { console.log(error); return response.send({ result: "no_connection" }); } }); function getVersion() { let pkgVersion = 'UNKNOWN'; let gitRevision = null; let gitBranch = null; try { const pkgJson = require('./package.json'); pkgVersion = pkgJson.version; if (!process['pkg'] && commandExistsSync('git')) { gitRevision = child_process .execSync('git rev-parse --short HEAD', { cwd: process.cwd(), stdio: ['ignore', 'pipe', 'ignore'] }) .toString().trim(); gitBranch = child_process .execSync('git rev-parse --abbrev-ref HEAD', { cwd: process.cwd(), stdio: ['ignore', 'pipe', 'ignore'] }) .toString().trim(); } } catch { // suppress exception } const agent = `SillyTavern:${pkgVersion}:Cohee#1207`; return { agent, pkgVersion, gitRevision, gitBranch }; } function tryParse(str) { try { return json5.parse(str); } catch { return undefined; } } function convertToV2(char) { // Simulate incoming data from frontend form const result = charaFormatData({ json_data: JSON.stringify(char), ch_name: char.name, description: char.description, personality: char.personality, scenario: char.scenario, first_mes: char.first_mes, mes_example: char.mes_example, creator_notes: char.creatorcomment, talkativeness: char.talkativeness, fav: char.fav, creator: char.creator, tags: char.tags, }); result.chat = char.chat ?? humanizedISO8601DateTime(); result.create_date = char.create_date ?? humanizedISO8601DateTime(); return result; } function unsetFavFlag(char) { _.set(char, 'fav', false); _.set(char, 'data.extensions.fav', false); } function readFromV2(char) { if (_.isUndefined(char.data)) { console.warn('Spec v2 data missing'); return char; } const fieldMappings = { name: 'name', description: 'description', personality: 'personality', scenario: 'scenario', first_mes: 'first_mes', mes_example: 'mes_example', talkativeness: 'extensions.talkativeness', fav: 'extensions.fav', tags: 'tags', }; _.forEach(fieldMappings, (v2Path, charField) => { //console.log(`Migrating field: ${charField} from ${v2Path}`); const v2Value = _.get(char.data, v2Path); if (_.isUndefined(v2Value)) { let defaultValue = undefined; // Backfill default values for missing ST extension fields if (v2Path === 'extensions.talkativeness') { defaultValue = 0.5; } if (v2Path === 'extensions.fav') { defaultValue = false; } if (!_.isUndefined(defaultValue)) { //console.debug(`Spec v2 extension data missing for field: ${charField}, using default value: ${defaultValue}`); char[charField] = defaultValue; } else { console.debug(`Spec v2 data missing for unknown field: ${charField}`); return; } } if (!_.isUndefined(char[charField]) && !_.isUndefined(v2Value) && String(char[charField]) !== String(v2Value)) { console.debug(`Spec v2 data mismatch with Spec v1 for field: ${charField}`, char[charField], v2Value); } char[charField] = v2Value; }); char['chat'] = char['chat'] ?? humanizedISO8601DateTime(); return char; } //***************** Main functions function charaFormatData(data) { // This is supposed to save all the foreign keys that ST doesn't care about const char = tryParse(data.json_data) || {}; // Checks if data.alternate_greetings is an array, a string, or neither, and acts accordingly. (expected to be an array of strings) const getAlternateGreetings = data => { if (Array.isArray(data.alternate_greetings)) return data.alternate_greetings if (typeof data.alternate_greetings === 'string') return [data.alternate_greetings] return [] } // Spec V1 fields _.set(char, 'name', data.ch_name); _.set(char, 'description', data.description || ''); _.set(char, 'personality', data.personality || ''); _.set(char, 'scenario', data.scenario || ''); _.set(char, 'first_mes', data.first_mes || ''); _.set(char, 'mes_example', data.mes_example || ''); // Old ST extension fields (for backward compatibility, will be deprecated) _.set(char, 'creatorcomment', data.creator_notes); _.set(char, 'avatar', 'none'); _.set(char, 'chat', data.ch_name + ' - ' + humanizedISO8601DateTime()); _.set(char, 'talkativeness', data.talkativeness); _.set(char, 'fav', data.fav == 'true'); // Spec V2 fields _.set(char, 'spec', 'chara_card_v2'); _.set(char, 'spec_version', '2.0'); _.set(char, 'data.name', data.ch_name); _.set(char, 'data.description', data.description || ''); _.set(char, 'data.personality', data.personality || ''); _.set(char, 'data.scenario', data.scenario || ''); _.set(char, 'data.first_mes', data.first_mes || ''); _.set(char, 'data.mes_example', data.mes_example || ''); // New V2 fields _.set(char, 'data.creator_notes', data.creator_notes || ''); _.set(char, 'data.system_prompt', data.system_prompt || ''); _.set(char, 'data.post_history_instructions', data.post_history_instructions || ''); _.set(char, 'data.tags', typeof data.tags == 'string' ? (data.tags.split(',').map(x => x.trim()).filter(x => x)) : data.tags || []); _.set(char, 'data.creator', data.creator || ''); _.set(char, 'data.character_version', data.character_version || ''); _.set(char, 'data.alternate_greetings', getAlternateGreetings(data)); // ST extension fields to V2 object _.set(char, 'data.extensions.talkativeness', data.talkativeness); _.set(char, 'data.extensions.fav', data.fav == 'true'); _.set(char, 'data.extensions.world', data.world || ''); //_.set(char, 'data.extensions.create_date', humanizedISO8601DateTime()); //_.set(char, 'data.extensions.avatar', 'none'); //_.set(char, 'data.extensions.chat', data.ch_name + ' - ' + humanizedISO8601DateTime()); if (data.world) { try { const file = readWorldInfoFile(data.world); // File was imported - save it to the character book if (file && file.originalData) { _.set(char, 'data.character_book', file.originalData); } // File was not imported - convert the world info to the character book if (file && file.entries) { _.set(char, 'data.character_book', convertWorldInfoToCharacterBook(data.world, file.entries)); } } catch { console.debug(`Failed to read world info file: ${data.world}. Character book will not be available.`); } } return char; } app.post("/createcharacter", urlencodedParser, async function (request, response) { if (!request.body) return response.sendStatus(400); request.body.ch_name = sanitize(request.body.ch_name); const char = JSON.stringify(charaFormatData(request.body)); const internalName = getPngName(request.body.ch_name); const avatarName = `${internalName}.png`; const defaultAvatar = './public/img/ai4.png'; const chatsPath = directories.chats + internalName; //path.join(chatsPath, internalName); if (!fs.existsSync(chatsPath)) fs.mkdirSync(chatsPath); if (!request.file) { charaWrite(defaultAvatar, char, internalName, response, avatarName); } else { const crop = tryParse(request.query.crop); const uploadPath = path.join(UPLOADS_PATH, request.file.filename); await charaWrite(uploadPath, char, internalName, response, avatarName, crop); fs.unlinkSync(uploadPath); } }); app.post('/renamechat', jsonParser, async function (request, response) { if (!request.body || !request.body.original_file || !request.body.renamed_file) { return response.sendStatus(400); } const pathToFolder = request.body.is_group ? directories.groupChats : path.join(directories.chats, String(request.body.avatar_url).replace('.png', '')); const pathToOriginalFile = path.join(pathToFolder, request.body.original_file); const pathToRenamedFile = path.join(pathToFolder, request.body.renamed_file); console.log('Old chat name', pathToOriginalFile); console.log('New chat name', pathToRenamedFile); if (!fs.existsSync(pathToOriginalFile) || fs.existsSync(pathToRenamedFile)) { console.log('Either Source or Destination files are not available'); return response.status(400).send({ error: true }); } console.log('Successfully renamed.'); fs.renameSync(pathToOriginalFile, pathToRenamedFile); return response.send({ ok: true }); }); app.post("/renamecharacter", jsonParser, async function (request, response) { if (!request.body.avatar_url || !request.body.new_name) { return response.sendStatus(400); } const oldAvatarName = request.body.avatar_url; const newName = sanitize(request.body.new_name); const oldInternalName = path.parse(request.body.avatar_url).name; const newInternalName = getPngName(newName); const newAvatarName = `${newInternalName}.png`; const oldAvatarPath = path.join(charactersPath, oldAvatarName); const oldChatsPath = path.join(chatsPath, oldInternalName); const newChatsPath = path.join(chatsPath, newInternalName); try { // Read old file, replace name int it const rawOldData = await charaRead(oldAvatarPath); if (rawOldData === false || rawOldData === undefined) throw new Error("Failed to read character file"); const oldData = getCharaCardV2(json5.parse(rawOldData)); _.set(oldData, 'data.name', newName); _.set(oldData, 'name', newName); const newData = JSON.stringify(oldData); // Write data to new location await charaWrite(oldAvatarPath, newData, newInternalName); // Rename chats folder if (fs.existsSync(oldChatsPath) && !fs.existsSync(newChatsPath)) { fs.renameSync(oldChatsPath, newChatsPath); } // Remove the old character file fs.rmSync(oldAvatarPath); // Return new avatar name to ST return response.send({ 'avatar': newAvatarName }); } catch (err) { console.error(err); return response.sendStatus(500); } }); app.post("/editcharacter", urlencodedParser, async function (request, response) { if (!request.body) { console.error('Error: no response body detected'); response.status(400).send('Error: no response body detected'); return; } if (request.body.ch_name === '' || request.body.ch_name === undefined || request.body.ch_name === '.') { console.error('Error: invalid name.'); response.status(400).send('Error: invalid name.'); return; } let char = charaFormatData(request.body); char.chat = request.body.chat; char.create_date = request.body.create_date; char = JSON.stringify(char); let target_img = (request.body.avatar_url).replace('.png', ''); try { if (!request.file) { const avatarPath = path.join(charactersPath, request.body.avatar_url); await charaWrite(avatarPath, char, target_img, response, 'Character saved'); } else { const crop = tryParse(request.query.crop); const newAvatarPath = path.join(UPLOADS_PATH, request.file.filename); invalidateThumbnail('avatar', request.body.avatar_url); await charaWrite(newAvatarPath, char, target_img, response, 'Character saved', crop); fs.unlinkSync(newAvatarPath); } } catch { console.error('An error occured, character edit invalidated.'); } }); /** * Handle a POST request to edit a character attribute. * * This function reads the character data from a file, updates the specified attribute, * and writes the updated data back to the file. * * @param {Object} request - The HTTP request object. * @param {Object} response - The HTTP response object. * @returns {void} */ app.post("/editcharacterattribute", jsonParser, async function (request, response) { console.log(request.body); if (!request.body) { console.error('Error: no response body detected'); response.status(400).send('Error: no response body detected'); return; } if (request.body.ch_name === '' || request.body.ch_name === undefined || request.body.ch_name === '.') { console.error('Error: invalid name.'); response.status(400).send('Error: invalid name.'); return; } try { const avatarPath = path.join(charactersPath, request.body.avatar_url); let charJSON = await charaRead(avatarPath); if (typeof charJSON !== 'string') throw new Error("Failed to read character file"); let char = JSON.parse(charJSON) //check if the field exists if (char[request.body.field] === undefined && char.data[request.body.field] === undefined) { console.error('Error: invalid field.'); response.status(400).send('Error: invalid field.'); return; } char[request.body.field] = request.body.value; char.data[request.body.field] = request.body.value; let newCharJSON = JSON.stringify(char); await charaWrite(avatarPath, newCharJSON, (request.body.avatar_url).replace('.png', ''), response, 'Character saved'); } catch (err) { console.error('An error occured, character edit invalidated.', err); } }); app.post("/deletecharacter", jsonParser, async function (request, response) { if (!request.body || !request.body.avatar_url) { return response.sendStatus(400); } if (request.body.avatar_url !== sanitize(request.body.avatar_url)) { console.error('Malicious filename prevented'); return response.sendStatus(403); } const avatarPath = charactersPath + request.body.avatar_url; if (!fs.existsSync(avatarPath)) { return response.sendStatus(400); } fs.rmSync(avatarPath); invalidateThumbnail('avatar', request.body.avatar_url); let dir_name = (request.body.avatar_url.replace('.png', '')); if (!dir_name.length) { console.error('Malicious dirname prevented'); return response.sendStatus(403); } if (request.body.delete_chats == true) { try { await fs.promises.rm(path.join(chatsPath, sanitize(dir_name)), { recursive: true, force: true }) } catch (err) { console.error(err); return response.sendStatus(500); } } return response.sendStatus(200); }); /** * @param {express.Response | undefined} response * @param {{file_name: string} | string} mes */ async function charaWrite(img_url, data, target_img, response = undefined, mes = 'ok', crop = undefined) { try { // Read the image, resize, and save it as a PNG into the buffer const image = await tryReadImage(img_url, crop); // Get the chunks const chunks = extract(image); const tEXtChunks = chunks.filter(chunk => chunk.name === 'tEXt'); // Remove all existing tEXt chunks for (let tEXtChunk of tEXtChunks) { chunks.splice(chunks.indexOf(tEXtChunk), 1); } // Add new chunks before the IEND chunk const base64EncodedData = Buffer.from(data, 'utf8').toString('base64'); chunks.splice(-1, 0, PNGtext.encode('chara', base64EncodedData)); //chunks.splice(-1, 0, text.encode('lorem', 'ipsum')); writeFileAtomicSync(charactersPath + target_img + '.png', Buffer.from(encode(chunks))); if (response !== undefined) response.send(mes); return true; } catch (err) { console.log(err); if (response !== undefined) response.status(500).send(err); return false; } } async function tryReadImage(img_url, crop) { try { let rawImg = await jimp.read(img_url); let final_width = rawImg.bitmap.width, final_height = rawImg.bitmap.height // Apply crop if defined if (typeof crop == 'object' && [crop.x, crop.y, crop.width, crop.height].every(x => typeof x === 'number')) { rawImg = rawImg.crop(crop.x, crop.y, crop.width, crop.height); // Apply standard resize if requested if (crop.want_resize) { final_width = AVATAR_WIDTH final_height = AVATAR_HEIGHT } else { final_width = crop.width; final_height = crop.height; } } const image = await rawImg.cover(final_width, final_height).getBufferAsync(jimp.MIME_PNG); return image; } // If it's an unsupported type of image (APNG) - just read the file as buffer catch { return fs.readFileSync(img_url); } } async function charaRead(img_url, input_format) { return characterCardParser.parse(img_url, input_format); } /** * calculateChatSize - Calculates the total chat size for a given character. * * @param {string} charDir The directory where the chats are stored. * @return { {chatSize: number, dateLastChat: number} } The total chat size. */ const calculateChatSize = (charDir) => { let chatSize = 0; let dateLastChat = 0; if (fs.existsSync(charDir)) { const chats = fs.readdirSync(charDir); if (Array.isArray(chats) && chats.length) { for (const chat of chats) { const chatStat = fs.statSync(path.join(charDir, chat)); chatSize += chatStat.size; dateLastChat = Math.max(dateLastChat, chatStat.mtimeMs); } } } return { chatSize, dateLastChat }; } // Calculate the total string length of the data object const calculateDataSize = (data) => { return typeof data === 'object' ? Object.values(data).reduce((acc, val) => acc + new String(val).length, 0) : 0; } /** * processCharacter - Process a given character, read its data and calculate its statistics. * * @param {string} item The name of the character. * @param {number} i The index of the character in the characters list. * @return {Promise} A Promise that resolves when the character processing is done. */ const processCharacter = async (item, i) => { try { const img_data = await charaRead(charactersPath + item); if (img_data === false || img_data === undefined) throw new Error("Failed to read character file"); let jsonObject = getCharaCardV2(json5.parse(img_data)); jsonObject.avatar = item; characters[i] = jsonObject; characters[i]['json_data'] = img_data; const charStat = fs.statSync(path.join(charactersPath, item)); characters[i]['date_added'] = charStat.birthtimeMs; characters[i]['create_date'] = jsonObject['create_date'] || humanizedISO8601DateTime(charStat.birthtimeMs); const char_dir = path.join(chatsPath, item.replace('.png', '')); const { chatSize, dateLastChat } = calculateChatSize(char_dir); characters[i]['chat_size'] = chatSize; characters[i]['date_last_chat'] = dateLastChat; characters[i]['data_size'] = calculateDataSize(jsonObject?.data); } catch (err) { characters[i] = { date_added: 0, date_last_chat: 0, chat_size: 0 }; console.log(`Could not process character: ${item}`); if (err instanceof SyntaxError) { console.log("String [" + i + "] is not valid JSON!"); } else { console.log("An unexpected error occurred: ", err); } } } /** * HTTP POST endpoint for the "/getcharacters" route. * * This endpoint is responsible for reading character files from the `charactersPath` directory, * parsing character data, calculating stats for each character and responding with the data. * Stats are calculated only on the first run, on subsequent runs the stats are fetched from * the `charStats` variable. * The stats are calculated by the `calculateStats` function. * The characters are processed by the `processCharacter` function. * * @param {object} request The HTTP request object. * @param {object} response The HTTP response object. * @return {undefined} Does not return a value. */ app.post("/getcharacters", jsonParser, function (request, response) { fs.readdir(charactersPath, async (err, files) => { if (err) { console.error(err); return; } const pngFiles = files.filter(file => file.endsWith('.png')); characters = {}; let processingPromises = pngFiles.map((file, index) => processCharacter(file, index)); await Promise.all(processingPromises); performance.mark('B'); response.send(JSON.stringify(characters)); }); }); app.post("/getonecharacter", jsonParser, async function (request, response) { if (!request.body) return response.sendStatus(400); const item = request.body.avatar_url; const filePath = path.join(charactersPath, item); if (!fs.existsSync(filePath)) { return response.sendStatus(404); } characters = {}; await processCharacter(item, 0); return response.send(characters[0]); }); /** * Handle a POST request to get the stats object * * This function returns the stats object that was calculated by the `calculateStats` function. * * * @param {Object} request - The HTTP request object. * @param {Object} response - The HTTP response object. * @returns {void} */ app.post("/getstats", jsonParser, function (request, response) { response.send(JSON.stringify(statsHelpers.getCharStats())); }); /** * Handle a POST request to update the stats object * * This function updates the stats object with the data from the request body. * * @param {Object} request - The HTTP request object. * @param {Object} response - The HTTP response object. * @returns {void} * */ app.post("/updatestats", jsonParser, function (request, response) { if (!request.body) return response.sendStatus(400); statsHelpers.setCharStats(request.body); return response.sendStatus(200); }); app.post("/getbackgrounds", jsonParser, function (request, response) { var images = getImages("public/backgrounds"); response.send(JSON.stringify(images)); }); app.post("/getuseravatars", jsonParser, function (request, response) { var images = getImages("public/User Avatars"); response.send(JSON.stringify(images)); }); app.post('/deleteuseravatar', jsonParser, function (request, response) { if (!request.body) return response.sendStatus(400); if (request.body.avatar !== sanitize(request.body.avatar)) { console.error('Malicious avatar name prevented'); return response.sendStatus(403); } const fileName = path.join(directories.avatars, sanitize(request.body.avatar)); if (fs.existsSync(fileName)) { fs.rmSync(fileName); return response.send({ result: 'ok' }); } return response.sendStatus(404); }); app.post("/setbackground", jsonParser, function (request, response) { try { const bg = `#bg1 {background-image: url('../backgrounds/${request.body.bg}');}`; writeFileAtomicSync('public/css/bg_load.css', bg, 'utf8'); response.send({ result: 'ok' }); } catch (err) { console.log(err); response.send(err); } }); app.post("/delbackground", jsonParser, function (request, response) { if (!request.body) return response.sendStatus(400); if (request.body.bg !== sanitize(request.body.bg)) { console.error('Malicious bg name prevented'); return response.sendStatus(403); } const fileName = path.join('public/backgrounds/', sanitize(request.body.bg)); if (!fs.existsSync(fileName)) { console.log('BG file not found'); return response.sendStatus(400); } fs.rmSync(fileName); invalidateThumbnail('bg', request.body.bg); return response.send('ok'); }); app.post("/delchat", jsonParser, function (request, response) { console.log('/delchat entered'); if (!request.body) { console.log('no request body seen'); return response.sendStatus(400); } if (request.body.chatfile !== sanitize(request.body.chatfile)) { console.error('Malicious chat name prevented'); return response.sendStatus(403); } const dirName = String(request.body.avatar_url).replace('.png', ''); const fileName = `${chatsPath + dirName}/${sanitize(String(request.body.chatfile))}`; const chatFileExists = fs.existsSync(fileName); if (!chatFileExists) { console.log(`Chat file not found '${fileName}'`); return response.sendStatus(400); } else { console.log('found the chat file: ' + fileName); /* fs.unlinkSync(fileName); */ fs.rmSync(fileName); console.log('deleted chat file: ' + fileName); } return response.send('ok'); }); app.post('/renamebackground', jsonParser, function (request, response) { if (!request.body) return response.sendStatus(400); const oldFileName = path.join('public/backgrounds/', sanitize(request.body.old_bg)); const newFileName = path.join('public/backgrounds/', sanitize(request.body.new_bg)); if (!fs.existsSync(oldFileName)) { console.log('BG file not found'); return response.sendStatus(400); } if (fs.existsSync(newFileName)) { console.log('New BG file already exists'); return response.sendStatus(400); } fs.renameSync(oldFileName, newFileName); invalidateThumbnail('bg', request.body.old_bg); return response.send('ok'); }); app.post("/downloadbackground", urlencodedParser, function (request, response) { response_dw_bg = response; if (!request.body || !request.file) return response.sendStatus(400); const img_path = path.join(UPLOADS_PATH, request.file.filename); const filename = request.file.originalname; try { fs.copyFileSync(img_path, path.join('public/backgrounds/', filename)); invalidateThumbnail('bg', filename); response_dw_bg.send(filename); fs.unlinkSync(img_path); } catch (err) { console.error(err); response_dw_bg.sendStatus(500); } }); app.post("/savesettings", jsonParser, function (request, response) { try { writeFileAtomicSync('public/settings.json', JSON.stringify(request.body, null, 4), 'utf8'); response.send({ result: "ok" }); } catch (err) { console.log(err); response.send(err); } }); function getCharaCardV2(jsonObject) { if (jsonObject.spec === undefined) { jsonObject = convertToV2(jsonObject); } else { jsonObject = readFromV2(jsonObject); } return jsonObject; } function readAndParseFromDirectory(directoryPath, fileExtension = '.json') { const files = fs .readdirSync(directoryPath) .filter(x => path.parse(x).ext == fileExtension) .sort(); const parsedFiles = []; files.forEach(item => { try { const file = fs.readFileSync(path.join(directoryPath, item), 'utf-8'); parsedFiles.push(fileExtension == '.json' ? json5.parse(file) : file); } catch { // skip } }); return parsedFiles; } function sortByModifiedDate(directory) { return (a, b) => +(new Date(fs.statSync(`${directory}/${b}`).mtime)) - +(new Date(fs.statSync(`${directory}/${a}`).mtime)); } function sortByName(_) { return (a, b) => a.localeCompare(b); } function readPresetsFromDirectory(directoryPath, options = {}) { const { sortFunction, removeFileExtension = false } = options; const files = fs.readdirSync(directoryPath).sort(sortFunction); const fileContents = []; const fileNames = []; files.forEach(item => { try { const file = fs.readFileSync(path.join(directoryPath, item), 'utf8'); json5.parse(file); fileContents.push(file); fileNames.push(removeFileExtension ? item.replace(/\.[^/.]+$/, '') : item); } catch { // skip console.log(`${item} is not a valid JSON`); } }); return { fileContents, fileNames }; } // Wintermute's code app.post('/getsettings', jsonParser, (request, response) => { let settings try { settings = fs.readFileSync('public/settings.json', 'utf8'); } catch (e) { return response.sendStatus(500); } // NovelAI Settings const { fileContents: novelai_settings, fileNames: novelai_setting_names } = readPresetsFromDirectory(directories.novelAI_Settings, { sortFunction: sortByName(directories.novelAI_Settings), removeFileExtension: true }); // OpenAI Settings const { fileContents: openai_settings, fileNames: openai_setting_names } = readPresetsFromDirectory(directories.openAI_Settings, { sortFunction: sortByModifiedDate(directories.openAI_Settings), removeFileExtension: true }); // TextGenerationWebUI Settings const { fileContents: textgenerationwebui_presets, fileNames: textgenerationwebui_preset_names } = readPresetsFromDirectory(directories.textGen_Settings, { sortFunction: sortByName(directories.textGen_Settings), removeFileExtension: true }); //Kobold const { fileContents: koboldai_settings, fileNames: koboldai_setting_names } = readPresetsFromDirectory(directories.koboldAI_Settings, { sortFunction: sortByName(directories.koboldAI_Settings), removeFileExtension: true }) const worldFiles = fs .readdirSync(directories.worlds) .filter(file => path.extname(file).toLowerCase() === '.json') .sort((a, b) => a.localeCompare(b)); const world_names = worldFiles.map(item => path.parse(item).name); const themes = readAndParseFromDirectory(directories.themes); const movingUIPresets = readAndParseFromDirectory(directories.movingUI); const quickReplyPresets = readAndParseFromDirectory(directories.quickreplies); const instruct = readAndParseFromDirectory(directories.instruct); const context = readAndParseFromDirectory(directories.context); response.send({ settings, koboldai_settings, koboldai_setting_names, world_names, novelai_settings, novelai_setting_names, openai_settings, openai_setting_names, textgenerationwebui_presets, textgenerationwebui_preset_names, themes, movingUIPresets, quickReplyPresets, instruct, context, enable_extensions: enableExtensions, }); }); app.post('/getworldinfo', jsonParser, (request, response) => { if (!request.body?.name) { return response.sendStatus(400); } const file = readWorldInfoFile(request.body.name); return response.send(file); }); app.post('/deleteworldinfo', jsonParser, (request, response) => { if (!request.body?.name) { return response.sendStatus(400); } const worldInfoName = request.body.name; const filename = sanitize(`${worldInfoName}.json`); const pathToWorldInfo = path.join(directories.worlds, filename); if (!fs.existsSync(pathToWorldInfo)) { throw new Error(`World info file ${filename} doesn't exist.`); } fs.rmSync(pathToWorldInfo); return response.sendStatus(200); }); app.post('/savetheme', jsonParser, (request, response) => { if (!request.body || !request.body.name) { return response.sendStatus(400); } const filename = path.join(directories.themes, sanitize(request.body.name) + '.json'); writeFileAtomicSync(filename, JSON.stringify(request.body, null, 4), 'utf8'); return response.sendStatus(200); }); app.post('/savemovingui', jsonParser, (request, response) => { if (!request.body || !request.body.name) { return response.sendStatus(400); } const filename = path.join(directories.movingUI, sanitize(request.body.name) + '.json'); writeFileAtomicSync(filename, JSON.stringify(request.body, null, 4), 'utf8'); return response.sendStatus(200); }); app.post('/savequickreply', jsonParser, (request, response) => { if (!request.body || !request.body.name) { return response.sendStatus(400); } const filename = path.join(directories.quickreplies, sanitize(request.body.name) + '.json'); writeFileAtomicSync(filename, JSON.stringify(request.body, null, 4), 'utf8'); return response.sendStatus(200); }); /** * @param {string} name Name of World Info file * @param {object} entries Entries object */ function convertWorldInfoToCharacterBook(name, entries) { /** @type {{ entries: object[]; name: string }} */ const result = { entries: [], name }; for (const index in entries) { const entry = entries[index]; const originalEntry = { id: entry.uid, keys: entry.key, secondary_keys: entry.keysecondary, comment: entry.comment, content: entry.content, constant: entry.constant, selective: entry.selective, insertion_order: entry.order, enabled: !entry.disable, position: entry.position == 0 ? 'before_char' : 'after_char', extensions: { position: entry.position, exclude_recursion: entry.excludeRecursion, display_index: entry.displayIndex, probability: entry.probability ?? null, useProbability: entry.useProbability ?? false, } }; result.entries.push(originalEntry); } return result; } function readWorldInfoFile(worldInfoName) { if (!worldInfoName) { return { entries: {} }; } const filename = `${worldInfoName}.json`; const pathToWorldInfo = path.join(directories.worlds, filename); if (!fs.existsSync(pathToWorldInfo)) { throw new Error(`World info file ${filename} doesn't exist.`); } const worldInfoText = fs.readFileSync(pathToWorldInfo, 'utf8'); const worldInfo = json5.parse(worldInfoText); return worldInfo; } function getImages(path) { return fs .readdirSync(path) .filter(file => { const type = mime.lookup(file); return type && type.startsWith('image/'); }) .sort(Intl.Collator().compare); } //***********Novel.ai API app.post("/getstatus_novelai", jsonParser, async function (request, response_getstatus_novel) { if (!request.body) return response_getstatus_novel.sendStatus(400); const api_key_novel = readSecret(SECRET_KEYS.NOVEL); if (!api_key_novel) { return response_getstatus_novel.sendStatus(401); } try { const response = await fetch(API_NOVELAI + "/user/subscription", { method: 'GET', headers: { 'Content-Type': 'application/json', 'Authorization': "Bearer " + api_key_novel, }, }); if (response.ok) { const data = await response.json(); return response_getstatus_novel.send(data); } else if (response.status == 401) { console.log('NovelAI Access Token is incorrect.'); return response_getstatus_novel.send({ error: true }); } else { console.log('NovelAI returned an error:', response.statusText); return response_getstatus_novel.send({ error: true }); } } catch (error) { console.log(error); return response_getstatus_novel.send({ error: true }); } }); app.post("/generate_novelai", jsonParser, async function (request, response_generate_novel) { if (!request.body) return response_generate_novel.sendStatus(400); const api_key_novel = readSecret(SECRET_KEYS.NOVEL); if (!api_key_novel) { return response_generate_novel.sendStatus(401); } const controller = new AbortController(); request.socket.removeAllListeners('close'); request.socket.on('close', function () { controller.abort(); }); const isNewModel = (request.body.model.includes('clio') || request.body.model.includes('kayra')); const badWordsList = novelai.getBadWordsList(request.body.model); // Add customized bad words for Clio and Kayra if (isNewModel && Array.isArray(request.body.bad_words_ids)) { for (const badWord of request.body.bad_words_ids) { if (Array.isArray(badWord) && badWord.every(x => Number.isInteger(x))) { badWordsList.push(badWord); } } } // Add default biases for dinkus and asterism const logit_bias_exp = isNewModel ? novelai.logitBiasExp.slice() : []; if (Array.isArray(logit_bias_exp) && Array.isArray(request.body.logit_bias_exp)) { logit_bias_exp.push(...request.body.logit_bias_exp); } const data = { "input": request.body.input, "model": request.body.model, "parameters": { "use_string": request.body.use_string ?? true, "temperature": request.body.temperature, "max_length": request.body.max_length, "min_length": request.body.min_length, "tail_free_sampling": request.body.tail_free_sampling, "repetition_penalty": request.body.repetition_penalty, "repetition_penalty_range": request.body.repetition_penalty_range, "repetition_penalty_slope": request.body.repetition_penalty_slope, "repetition_penalty_frequency": request.body.repetition_penalty_frequency, "repetition_penalty_presence": request.body.repetition_penalty_presence, "repetition_penalty_whitelist": isNewModel ? novelai.repPenaltyAllowList : null, "top_a": request.body.top_a, "top_p": request.body.top_p, "top_k": request.body.top_k, "typical_p": request.body.typical_p, "mirostat_lr": request.body.mirostat_lr, "mirostat_tau": request.body.mirostat_tau, "cfg_scale": request.body.cfg_scale, "cfg_uc": request.body.cfg_uc, "phrase_rep_pen": request.body.phrase_rep_pen, "stop_sequences": request.body.stop_sequences, "bad_words_ids": badWordsList, "logit_bias_exp": logit_bias_exp, "generate_until_sentence": request.body.generate_until_sentence, "use_cache": request.body.use_cache, "return_full_text": request.body.return_full_text, "prefix": request.body.prefix, "order": request.body.order } }; console.log(util.inspect(data, { depth: 4 })) const args = { body: JSON.stringify(data), headers: { "Content-Type": "application/json", "Authorization": "Bearer " + api_key_novel }, signal: controller.signal, }; try { const url = request.body.streaming ? `${API_NOVELAI}/ai/generate-stream` : `${API_NOVELAI}/ai/generate`; const response = await fetch(url, { method: 'POST', timeout: 0, ...args }); if (request.body.streaming) { // Pipe remote SSE stream to Express response response.body.pipe(response_generate_novel); request.socket.on('close', function () { if (response.body instanceof Readable) response.body.destroy(); // Close the remote stream response_generate_novel.end(); // End the Express response }); response.body.on('end', function () { console.log("Streaming request finished"); response_generate_novel.end(); }); } else { if (!response.ok) { const text = await response.text(); let message = text; console.log(`Novel API returned error: ${response.status} ${response.statusText} ${text}`); try { const data = JSON.parse(text); message = data.message; } catch { // ignore } return response_generate_novel.status(response.status).send({ error: { message } }); } const data = await response.json(); console.log(data); return response_generate_novel.send(data); } } catch (error) { return response_generate_novel.send({ error: true }); } }); app.post("/getallchatsofcharacter", jsonParser, function (request, response) { if (!request.body) return response.sendStatus(400); var char_dir = (request.body.avatar_url).replace('.png', '') fs.readdir(chatsPath + char_dir, (err, files) => { if (err) { console.log('found error in history loading'); console.error(err); response.send({ error: true }); return; } // filter for JSON files const jsonFiles = files.filter(file => path.extname(file) === '.jsonl'); // sort the files by name //jsonFiles.sort().reverse(); // print the sorted file names var chatData = {}; let ii = jsonFiles.length; //this is the number of files belonging to the character if (ii !== 0) { //console.log('found '+ii+' chat logs to load'); for (let i = jsonFiles.length - 1; i >= 0; i--) { const file = jsonFiles[i]; const fileStream = fs.createReadStream(chatsPath + char_dir + '/' + file); const fullPathAndFile = chatsPath + char_dir + '/' + file const stats = fs.statSync(fullPathAndFile); const fileSizeInKB = (stats.size / 1024).toFixed(2) + "kb"; //console.log(fileSizeInKB); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); let lastLine; let itemCounter = 0; rl.on('line', (line) => { itemCounter++; lastLine = line; }); rl.on('close', () => { ii--; if (lastLine) { let jsonData = tryParse(lastLine); if (jsonData && (jsonData.name !== undefined || jsonData.character_name !== undefined)) { chatData[i] = {}; chatData[i]['file_name'] = file; chatData[i]['file_size'] = fileSizeInKB; chatData[i]['chat_items'] = itemCounter - 1; chatData[i]['mes'] = jsonData['mes'] || '[The chat is empty]'; chatData[i]['last_mes'] = jsonData['send_date'] || Date.now(); } else { console.log('Found an invalid or corrupted chat file: ' + fullPathAndFile); } } if (ii === 0) { //console.log('ii count went to zero, responding with chatData'); response.send(chatData); } //console.log('successfully closing getallchatsofcharacter'); rl.close(); }); }; } else { //console.log('Found No Chats. Exiting Load Routine.'); response.send({ error: true }); }; }) }); function getPngName(file) { let i = 1; let base_name = file; while (fs.existsSync(charactersPath + file + '.png')) { file = base_name + i; i++; } return file; } app.post("/importcharacter", urlencodedParser, async function (request, response) { if (!request.body || request.file === undefined) return response.sendStatus(400); let png_name = ''; let filedata = request.file; let uploadPath = path.join(UPLOADS_PATH, filedata.filename); var format = request.body.file_type; const defaultAvatarPath = './public/img/ai4.png'; //console.log(format); if (filedata) { if (format == 'json') { fs.readFile(uploadPath, 'utf8', async (err, data) => { fs.unlinkSync(uploadPath); if (err) { console.log(err); response.send({ error: true }); } let jsonData = json5.parse(data); if (jsonData.spec !== undefined) { console.log('importing from v2 json'); importRisuSprites(jsonData); unsetFavFlag(jsonData); jsonData = readFromV2(jsonData); png_name = getPngName(jsonData.data?.name || jsonData.name); let char = JSON.stringify(jsonData); charaWrite(defaultAvatarPath, char, png_name, response, { file_name: png_name }); } else if (jsonData.name !== undefined) { console.log('importing from v1 json'); jsonData.name = sanitize(jsonData.name); if (jsonData.creator_notes) { jsonData.creator_notes = jsonData.creator_notes.replace("Creator's notes go here.", ""); } png_name = getPngName(jsonData.name); let char = { "name": jsonData.name, "description": jsonData.description ?? '', "creatorcomment": jsonData.creatorcomment ?? jsonData.creator_notes ?? '', "personality": jsonData.personality ?? '', "first_mes": jsonData.first_mes ?? '', "avatar": 'none', "chat": jsonData.name + " - " + humanizedISO8601DateTime(), "mes_example": jsonData.mes_example ?? '', "scenario": jsonData.scenario ?? '', "create_date": humanizedISO8601DateTime(), "talkativeness": jsonData.talkativeness ?? 0.5, "creator": jsonData.creator ?? '', "tags": jsonData.tags ?? '', }; char = convertToV2(char); let charJSON = JSON.stringify(char); charaWrite(defaultAvatarPath, charJSON, png_name, response, { file_name: png_name }); } else if (jsonData.char_name !== undefined) {//json Pygmalion notepad console.log('importing from gradio json'); jsonData.char_name = sanitize(jsonData.char_name); if (jsonData.creator_notes) { jsonData.creator_notes = jsonData.creator_notes.replace("Creator's notes go here.", ""); } png_name = getPngName(jsonData.char_name); let char = { "name": jsonData.char_name, "description": jsonData.char_persona ?? '', "creatorcomment": jsonData.creatorcomment ?? jsonData.creator_notes ?? '', "personality": '', "first_mes": jsonData.char_greeting ?? '', "avatar": 'none', "chat": jsonData.name + " - " + humanizedISO8601DateTime(), "mes_example": jsonData.example_dialogue ?? '', "scenario": jsonData.world_scenario ?? '', "create_date": humanizedISO8601DateTime(), "talkativeness": jsonData.talkativeness ?? 0.5, "creator": jsonData.creator ?? '', "tags": jsonData.tags ?? '', }; char = convertToV2(char); let charJSON = JSON.stringify(char); charaWrite(defaultAvatarPath, charJSON, png_name, response, { file_name: png_name }); } else { console.log('Incorrect character format .json'); response.send({ error: true }); } }); } else { try { var img_data = await charaRead(uploadPath, format); if (img_data === false || img_data === undefined) throw new Error('Failed to read character data'); let jsonData = json5.parse(img_data); jsonData.name = sanitize(jsonData.data?.name || jsonData.name); png_name = getPngName(jsonData.name); if (format == 'webp') { try { let convertedPath = path.join(UPLOADS_PATH, path.basename(uploadPath, ".webp") + ".png") await webp.dwebp(uploadPath, convertedPath, "-o"); fs.unlinkSync(uploadPath); uploadPath = convertedPath; } catch { console.error('WEBP image conversion failed. Using the default character image.'); uploadPath = defaultAvatarPath; } } if (jsonData.spec !== undefined) { console.log('Found a v2 character file.'); importRisuSprites(jsonData); unsetFavFlag(jsonData); jsonData = readFromV2(jsonData); jsonData["create_date"] = humanizedISO8601DateTime(); const char = JSON.stringify(jsonData); await charaWrite(uploadPath, char, png_name, response, { file_name: png_name }); fs.unlinkSync(uploadPath); } else if (jsonData.name !== undefined) { console.log('Found a v1 character file.'); if (jsonData.creator_notes) { jsonData.creator_notes = jsonData.creator_notes.replace("Creator's notes go here.", ""); } let char = { "name": jsonData.name, "description": jsonData.description ?? '', "creatorcomment": jsonData.creatorcomment ?? jsonData.creator_notes ?? '', "personality": jsonData.personality ?? '', "first_mes": jsonData.first_mes ?? '', "avatar": 'none', "chat": jsonData.name + " - " + humanizedISO8601DateTime(), "mes_example": jsonData.mes_example ?? '', "scenario": jsonData.scenario ?? '', "create_date": humanizedISO8601DateTime(), "talkativeness": jsonData.talkativeness ?? 0.5, "creator": jsonData.creator ?? '', "tags": jsonData.tags ?? '', }; char = convertToV2(char); const charJSON = JSON.stringify(char); await charaWrite(uploadPath, charJSON, png_name, response, { file_name: png_name }); fs.unlinkSync(uploadPath); } else { console.log('Unknown character card format'); response.send({ error: true }); } } catch (err) { console.log(err); response.send({ error: true }); } } } }); app.post("/dupecharacter", jsonParser, async function (request, response) { try { if (!request.body.avatar_url) { console.log("avatar URL not found in request body"); console.log(request.body); return response.sendStatus(400); } let filename = path.join(directories.characters, sanitize(request.body.avatar_url)); if (!fs.existsSync(filename)) { console.log('file for dupe not found'); console.log(filename); return response.sendStatus(404); } let suffix = 1; let newFilename = filename; // If filename ends with a _number, increment the number const nameParts = path.basename(filename, path.extname(filename)).split('_'); const lastPart = nameParts[nameParts.length - 1]; let baseName; if (!isNaN(Number(lastPart)) && nameParts.length > 1) { suffix = parseInt(lastPart) + 1; baseName = nameParts.slice(0, -1).join("_"); // construct baseName without suffix } else { baseName = nameParts.join("_"); // original filename is completely the baseName } newFilename = path.join(directories.characters, `${baseName}_${suffix}${path.extname(filename)}`); while (fs.existsSync(newFilename)) { let suffixStr = "_" + suffix; newFilename = path.join(directories.characters, `${baseName}${suffixStr}${path.extname(filename)}`); suffix++; } fs.copyFileSync(filename, newFilename); console.log(`${filename} was copied to ${newFilename}`); response.sendStatus(200); } catch (error) { console.error(error); return response.send({ error: true }); } }); app.post("/exportchat", jsonParser, async function (request, response) { if (!request.body.file || (!request.body.avatar_url && request.body.is_group === false)) { return response.sendStatus(400); } const pathToFolder = request.body.is_group ? directories.groupChats : path.join(directories.chats, String(request.body.avatar_url).replace('.png', '')); let filename = path.join(pathToFolder, request.body.file); let exportfilename = request.body.exportfilename if (!fs.existsSync(filename)) { const errorMessage = { message: `Could not find JSONL file to export. Source chat file: ${filename}.` } console.log(errorMessage.message); return response.status(404).json(errorMessage); } try { // Short path for JSONL files if (request.body.format == 'jsonl') { try { const rawFile = fs.readFileSync(filename, 'utf8'); const successMessage = { message: `Chat saved to ${exportfilename}`, result: rawFile, } console.log(`Chat exported as ${exportfilename}`); return response.status(200).json(successMessage); } catch (err) { console.error(err); const errorMessage = { message: `Could not read JSONL file to export. Source chat file: ${filename}.` } console.log(errorMessage.message); return response.status(500).json(errorMessage); } } const readStream = fs.createReadStream(filename); const rl = readline.createInterface({ input: readStream, }); let buffer = ''; rl.on('line', (line) => { const data = JSON.parse(line); if (data.mes) { const name = data.name; const message = (data?.extra?.display_text || data?.mes || '').replace(/\r?\n/g, '\n'); buffer += (`${name}: ${message}\n\n`); } }); rl.on('close', () => { const successMessage = { message: `Chat saved to ${exportfilename}`, result: buffer, } console.log(`Chat exported as ${exportfilename}`); return response.status(200).json(successMessage); }); } catch (err) { console.log("chat export failed.") console.log(err); return response.sendStatus(400); } }) app.post("/exportcharacter", jsonParser, async function (request, response) { if (!request.body.format || !request.body.avatar_url) { return response.sendStatus(400); } let filename = path.join(directories.characters, sanitize(request.body.avatar_url)); if (!fs.existsSync(filename)) { return response.sendStatus(404); } switch (request.body.format) { case 'png': return response.sendFile(filename, { root: process.cwd() }); case 'json': { try { let json = await charaRead(filename); if (json === false || json === undefined) return response.sendStatus(400); let jsonObject = getCharaCardV2(json5.parse(json)); return response.type('json').send(jsonObject) } catch { return response.sendStatus(400); } } case 'webp': { try { let json = await charaRead(filename); if (json === false || json === undefined) return response.sendStatus(400); let stringByteArray = utf8Encode.encode(json).toString(); let inputWebpPath = path.join(UPLOADS_PATH, `${Date.now()}_input.webp`); let outputWebpPath = path.join(UPLOADS_PATH, `${Date.now()}_output.webp`); let metadataPath = path.join(UPLOADS_PATH, `${Date.now()}_metadata.exif`); let metadata = { "Exif": { [exif.ExifIFD.UserComment]: stringByteArray, }, }; const exifString = exif.dump(metadata); writeFileAtomicSync(metadataPath, exifString, 'binary'); await webp.cwebp(filename, inputWebpPath, '-q 95'); await webp.webpmux_add(inputWebpPath, outputWebpPath, metadataPath, 'exif'); response.sendFile(outputWebpPath, { root: process.cwd() }, () => { fs.rmSync(inputWebpPath); fs.rmSync(metadataPath); fs.rmSync(outputWebpPath); }); return; } catch (err) { console.log(err); return response.sendStatus(400); } } } return response.sendStatus(400); }); app.post("/importgroupchat", urlencodedParser, function (request, response) { try { const filedata = request.file; if (!filedata) { return response.sendStatus(400); } const chatname = humanizedISO8601DateTime(); const pathToUpload = path.join(UPLOADS_PATH, filedata.filename); const pathToNewFile = path.join(directories.groupChats, `${chatname}.jsonl`); fs.copyFileSync(pathToUpload, pathToNewFile); fs.unlinkSync(pathToUpload); return response.send({ res: chatname }); } catch (error) { console.error(error); return response.send({ error: true }); } }); app.post("/importchat", urlencodedParser, function (request, response) { if (!request.body) return response.sendStatus(400); var format = request.body.file_type; let filedata = request.file; let avatar_url = (request.body.avatar_url).replace('.png', ''); let ch_name = request.body.character_name; let user_name = request.body.user_name || 'You'; if (!filedata) { return response.sendStatus(400); } try { const data = fs.readFileSync(path.join(UPLOADS_PATH, filedata.filename), 'utf8'); if (format === 'json') { const jsonData = json5.parse(data); if (jsonData.histories !== undefined) { //console.log('/importchat confirms JSON histories are defined'); const chat = { from(history) { return [ { user_name: user_name, character_name: ch_name, create_date: humanizedISO8601DateTime(), }, ...history.msgs.map( (message) => ({ name: message.src.is_human ? user_name : ch_name, is_user: message.src.is_human, is_name: true, send_date: humanizedISO8601DateTime(), mes: message.text, }) )]; } } const newChats = []; (jsonData.histories.histories ?? []).forEach((history) => { newChats.push(chat.from(history)); }); const errors = []; for (const chat of newChats) { const filePath = `${chatsPath + avatar_url}/${ch_name} - ${humanizedISO8601DateTime()} imported.jsonl`; const fileContent = chat.map(tryParse).filter(x => x).join('\n'); try { writeFileAtomicSync(filePath, fileContent, 'utf8'); } catch (err) { errors.push(err); } } if (0 < errors.length) { response.send('Errors occurred while writing character files. Errors: ' + JSON.stringify(errors)); } response.send({ res: true }); } else if (Array.isArray(jsonData.data_visible)) { // oobabooga's format /** @type {object[]} */ const chat = [{ user_name: user_name, character_name: ch_name, create_date: humanizedISO8601DateTime(), }]; for (const arr of jsonData.data_visible) { if (arr[0]) { const userMessage = { name: user_name, is_user: true, is_name: true, send_date: humanizedISO8601DateTime(), mes: arr[0], }; chat.push(userMessage); } if (arr[1]) { const charMessage = { name: ch_name, is_user: false, is_name: true, send_date: humanizedISO8601DateTime(), mes: arr[1], }; chat.push(charMessage); } } const chatContent = chat.map(obj => JSON.stringify(obj)).join('\n'); writeFileAtomicSync(`${chatsPath + avatar_url}/${ch_name} - ${humanizedISO8601DateTime()} imported.jsonl`, chatContent, 'utf8'); response.send({ res: true }); } else { console.log('Incorrect chat format .json'); return response.send({ error: true }); } } if (format === 'jsonl') { const line = data.split('\n')[0]; let jsonData = json5.parse(line); if (jsonData.user_name !== undefined || jsonData.name !== undefined) { fs.copyFileSync(path.join(UPLOADS_PATH, filedata.filename), (`${chatsPath + avatar_url}/${ch_name} - ${humanizedISO8601DateTime()}.jsonl`)); response.send({ res: true }); } else { console.log('Incorrect chat format .jsonl'); return response.send({ error: true }); } } } catch (error) { console.error(error); return response.send({ error: true }); } }); app.post('/importworldinfo', urlencodedParser, (request, response) => { if (!request.file) return response.sendStatus(400); const filename = `${path.parse(sanitize(request.file.originalname)).name}.json`; let fileContents = null; if (request.body.convertedData) { fileContents = request.body.convertedData; } else { const pathToUpload = path.join(UPLOADS_PATH, request.file.filename); fileContents = fs.readFileSync(pathToUpload, 'utf8'); fs.unlinkSync(pathToUpload); } try { const worldContent = json5.parse(fileContents); if (!('entries' in worldContent)) { throw new Error('File must contain a world info entries list'); } } catch (err) { return response.status(400).send('Is not a valid world info file'); } const pathToNewFile = path.join(directories.worlds, filename); const worldName = path.parse(pathToNewFile).name; if (!worldName) { return response.status(400).send('World file must have a name'); } writeFileAtomicSync(pathToNewFile, fileContents); return response.send({ name: worldName }); }); app.post('/editworldinfo', jsonParser, (request, response) => { if (!request.body) { return response.sendStatus(400); } if (!request.body.name) { return response.status(400).send('World file must have a name'); } try { if (!('entries' in request.body.data)) { throw new Error('World info must contain an entries list'); } } catch (err) { return response.status(400).send('Is not a valid world info file'); } const filename = `${sanitize(request.body.name)}.json`; const pathToFile = path.join(directories.worlds, filename); writeFileAtomicSync(pathToFile, JSON.stringify(request.body.data, null, 4)); return response.send({ ok: true }); }); app.post('/uploaduseravatar', urlencodedParser, async (request, response) => { if (!request.file) return response.sendStatus(400); try { const pathToUpload = path.join(UPLOADS_PATH, request.file.filename); const crop = tryParse(request.query.crop); let rawImg = await jimp.read(pathToUpload); if (typeof crop == 'object' && [crop.x, crop.y, crop.width, crop.height].every(x => typeof x === 'number')) { rawImg = rawImg.crop(crop.x, crop.y, crop.width, crop.height); } const image = await rawImg.cover(AVATAR_WIDTH, AVATAR_HEIGHT).getBufferAsync(jimp.MIME_PNG); const filename = request.body.overwrite_name || `${Date.now()}.png`; const pathToNewFile = path.join(directories.avatars, filename); writeFileAtomicSync(pathToNewFile, image); fs.rmSync(pathToUpload); return response.send({ path: filename }); } catch (err) { return response.status(400).send('Is not a valid image'); } }); /** * Ensure the directory for the provided file path exists. * If not, it will recursively create the directory. * * @param {string} filePath - The full path of the file for which the directory should be ensured. */ function ensureDirectoryExistence(filePath) { const dirname = path.dirname(filePath); if (fs.existsSync(dirname)) { return true; } ensureDirectoryExistence(dirname); fs.mkdirSync(dirname); } /** * Endpoint to handle image uploads. * The image should be provided in the request body in base64 format. * Optionally, a character name can be provided to save the image in a sub-folder. * * @route POST /uploadimage * @param {Object} request.body - The request payload. * @param {string} request.body.image - The base64 encoded image data. * @param {string} [request.body.ch_name] - Optional character name to determine the sub-directory. * @returns {Object} response - The response object containing the path where the image was saved. */ app.post('/uploadimage', jsonParser, async (request, response) => { // Check for image data if (!request.body || !request.body.image) { return response.status(400).send({ error: "No image data provided" }); } // Extracting the base64 data and the image format const match = request.body.image.match(/^data:image\/(png|jpg|webp|jpeg|gif);base64,(.+)$/); if (!match) { return response.status(400).send({ error: "Invalid image format" }); } const [, format, base64Data] = match; // Constructing filename and path let filename = `${Date.now()}.${format}`; if (request.body.filename) { filename = `${request.body.filename}.${format}`; } // if character is defined, save to a sub folder for that character let pathToNewFile = path.join(directories.userImages, filename); if (request.body.ch_name) { pathToNewFile = path.join(directories.userImages, request.body.ch_name, filename); } try { ensureDirectoryExistence(pathToNewFile); const imageBuffer = Buffer.from(base64Data, 'base64'); await fs.promises.writeFile(pathToNewFile, imageBuffer); // send the path to the image, relative to the client folder, which means removing the first folder from the path which is 'public' pathToNewFile = pathToNewFile.split(path.sep).slice(1).join(path.sep); response.send({ path: pathToNewFile }); } catch (error) { console.log(error); response.status(500).send({ error: "Failed to save the image" }); } }); app.post('/listimgfiles/:folder', (req, res) => { const directoryPath = path.join(process.cwd(), 'public/user/images/', sanitize(req.params.folder)); if (!fs.existsSync(directoryPath)) { fs.mkdirSync(directoryPath, { recursive: true }); } try { const images = getImages(directoryPath); return res.send(images); } catch (error) { console.error(error); return res.status(500).send({ error: "Unable to retrieve files" }); } }); app.post('/getgroups', jsonParser, (_, response) => { const groups = []; if (!fs.existsSync(directories.groups)) { fs.mkdirSync(directories.groups); } const files = fs.readdirSync(directories.groups).filter(x => path.extname(x) === '.json'); const chats = fs.readdirSync(directories.groupChats).filter(x => path.extname(x) === '.jsonl'); files.forEach(function (file) { try { const filePath = path.join(directories.groups, file); const fileContents = fs.readFileSync(filePath, 'utf8'); const group = json5.parse(fileContents); const groupStat = fs.statSync(filePath); group['date_added'] = groupStat.birthtimeMs; let chat_size = 0; let date_last_chat = 0; if (Array.isArray(group.chats) && Array.isArray(chats)) { for (const chat of chats) { if (group.chats.includes(path.parse(chat).name)) { const chatStat = fs.statSync(path.join(directories.groupChats, chat)); chat_size += chatStat.size; date_last_chat = Math.max(date_last_chat, chatStat.mtimeMs); } } } group['date_last_chat'] = date_last_chat; group['chat_size'] = chat_size; groups.push(group); } catch (error) { console.error(error); } }); return response.send(groups); }); app.post('/creategroup', jsonParser, (request, response) => { if (!request.body) { return response.sendStatus(400); } const id = String(Date.now()); const groupMetadata = { id: id, name: request.body.name ?? 'New Group', members: request.body.members ?? [], avatar_url: request.body.avatar_url, allow_self_responses: !!request.body.allow_self_responses, activation_strategy: request.body.activation_strategy ?? 0, disabled_members: request.body.disabled_members ?? [], chat_metadata: request.body.chat_metadata ?? {}, fav: request.body.fav, chat_id: request.body.chat_id ?? id, chats: request.body.chats ?? [id], }; const pathToFile = path.join(directories.groups, `${id}.json`); const fileData = JSON.stringify(groupMetadata); if (!fs.existsSync(directories.groups)) { fs.mkdirSync(directories.groups); } writeFileAtomicSync(pathToFile, fileData); return response.send(groupMetadata); }); app.post('/editgroup', jsonParser, (request, response) => { if (!request.body || !request.body.id) { return response.sendStatus(400); } const id = request.body.id; const pathToFile = path.join(directories.groups, `${id}.json`); const fileData = JSON.stringify(request.body); writeFileAtomicSync(pathToFile, fileData); return response.send({ ok: true }); }); app.post('/getgroupchat', jsonParser, (request, response) => { if (!request.body || !request.body.id) { return response.sendStatus(400); } const id = request.body.id; const pathToFile = path.join(directories.groupChats, `${id}.jsonl`); if (fs.existsSync(pathToFile)) { const data = fs.readFileSync(pathToFile, 'utf8'); const lines = data.split('\n'); // Iterate through the array of strings and parse each line as JSON const jsonData = lines.map(line => tryParse(line)).filter(x => x); return response.send(jsonData); } else { return response.send([]); } }); app.post('/deletegroupchat', jsonParser, (request, response) => { if (!request.body || !request.body.id) { return response.sendStatus(400); } const id = request.body.id; const pathToFile = path.join(directories.groupChats, `${id}.jsonl`); if (fs.existsSync(pathToFile)) { fs.rmSync(pathToFile); return response.send({ ok: true }); } return response.send({ error: true }); }); app.post('/savegroupchat', jsonParser, (request, response) => { if (!request.body || !request.body.id) { return response.sendStatus(400); } const id = request.body.id; const pathToFile = path.join(directories.groupChats, `${id}.jsonl`); if (!fs.existsSync(directories.groupChats)) { fs.mkdirSync(directories.groupChats); } let chat_data = request.body.chat; let jsonlData = chat_data.map(JSON.stringify).join('\n'); writeFileAtomicSync(pathToFile, jsonlData, 'utf8'); return response.send({ ok: true }); }); app.post('/deletegroup', jsonParser, async (request, response) => { if (!request.body || !request.body.id) { return response.sendStatus(400); } const id = request.body.id; const pathToGroup = path.join(directories.groups, sanitize(`${id}.json`)); try { // Delete group chats const group = json5.parse(fs.readFileSync(pathToGroup, 'utf8')); if (group && Array.isArray(group.chats)) { for (const chat of group.chats) { console.log('Deleting group chat', chat); const pathToFile = path.join(directories.groupChats, `${id}.jsonl`); if (fs.existsSync(pathToFile)) { fs.rmSync(pathToFile); } } } } catch (error) { console.error('Could not delete group chats. Clean them up manually.', error); } if (fs.existsSync(pathToGroup)) { fs.rmSync(pathToGroup); } return response.send({ ok: true }); }); /** * Discover the extension folders * If the folder is called third-party, search for subfolders instead */ app.get('/discover_extensions', jsonParser, function (_, response) { // get all folders in the extensions folder, except third-party const extensions = fs .readdirSync(directories.extensions) .filter(f => fs.statSync(path.join(directories.extensions, f)).isDirectory()) .filter(f => f !== 'third-party'); // get all folders in the third-party folder, if it exists if (!fs.existsSync(path.join(directories.extensions, 'third-party'))) { return response.send(extensions); } const thirdPartyExtensions = fs .readdirSync(path.join(directories.extensions, 'third-party')) .filter(f => fs.statSync(path.join(directories.extensions, 'third-party', f)).isDirectory()); // add the third-party extensions to the extensions array extensions.push(...thirdPartyExtensions.map(f => `third-party/${f}`)); console.log(extensions); return response.send(extensions); }); app.get('/get_sprites', jsonParser, function (request, response) { const name = String(request.query.name); const spritesPath = path.join(directories.characters, name); let sprites = []; try { if (fs.existsSync(spritesPath) && fs.statSync(spritesPath).isDirectory()) { sprites = fs.readdirSync(spritesPath) .filter(file => { const mimeType = mime.lookup(file); return mimeType && mimeType.startsWith('image/'); }) .map((file) => { const pathToSprite = path.join(spritesPath, file); return { label: path.parse(pathToSprite).name.toLowerCase(), path: `/characters/${name}/${file}`, }; }); } } catch (err) { console.log(err); } finally { return response.send(sprites); } }); function getThumbnailFolder(type) { let thumbnailFolder; switch (type) { case 'bg': thumbnailFolder = directories.thumbnailsBg; break; case 'avatar': thumbnailFolder = directories.thumbnailsAvatar; break; } return thumbnailFolder; } function getOriginalFolder(type) { let originalFolder; switch (type) { case 'bg': originalFolder = directories.backgrounds; break; case 'avatar': originalFolder = directories.characters; break; } return originalFolder; } function invalidateThumbnail(type, file) { const folder = getThumbnailFolder(type); if (folder === undefined) throw new Error("Invalid thumbnail type") const pathToThumbnail = path.join(folder, file); if (fs.existsSync(pathToThumbnail)) { fs.rmSync(pathToThumbnail); } } function cleanUploads() { try { if (fs.existsSync(UPLOADS_PATH)) { const uploads = fs.readdirSync(UPLOADS_PATH); if (!uploads.length) { return; } console.debug(`Cleaning uploads folder (${uploads.length} files)`); uploads.forEach(file => { const pathToFile = path.join(UPLOADS_PATH, file); fs.unlinkSync(pathToFile); }); } } catch (err) { console.error(err); } } async function ensureThumbnailCache() { const cacheFiles = fs.readdirSync(directories.thumbnailsBg); // files exist, all ok if (cacheFiles.length) { return; } console.log('Generating thumbnails cache. Please wait...'); const bgFiles = fs.readdirSync(directories.backgrounds); const tasks = []; for (const file of bgFiles) { tasks.push(generateThumbnail('bg', file)); } await Promise.all(tasks); console.log(`Done! Generated: ${bgFiles.length} preview images`); } async function generateThumbnail(type, file) { let thumbnailFolder = getThumbnailFolder(type) let originalFolder = getOriginalFolder(type) if (thumbnailFolder === undefined || originalFolder === undefined) throw new Error("Invalid thumbnail type") const pathToCachedFile = path.join(thumbnailFolder, file); const pathToOriginalFile = path.join(originalFolder, file); const cachedFileExists = fs.existsSync(pathToCachedFile); const originalFileExists = fs.existsSync(pathToOriginalFile); // to handle cases when original image was updated after thumb creation let shouldRegenerate = false; if (cachedFileExists && originalFileExists) { const originalStat = fs.statSync(pathToOriginalFile); const cachedStat = fs.statSync(pathToCachedFile); if (originalStat.mtimeMs > cachedStat.ctimeMs) { //console.log('Original file changed. Regenerating thumbnail...'); shouldRegenerate = true; } } if (cachedFileExists && !shouldRegenerate) { return pathToCachedFile; } if (!originalFileExists) { return null; } const imageSizes = { 'bg': [160, 90], 'avatar': [96, 144] }; const mySize = imageSizes[type]; try { let buffer; try { const image = await jimp.read(pathToOriginalFile); buffer = await image.cover(mySize[0], mySize[1]).quality(95).getBufferAsync('image/jpeg'); } catch (inner) { console.warn(`Thumbnailer can not process the image: ${pathToOriginalFile}. Using original size`); buffer = fs.readFileSync(pathToOriginalFile); } writeFileAtomicSync(pathToCachedFile, buffer); } catch (outer) { return null; } return pathToCachedFile; } app.get('/thumbnail', jsonParser, async function (request, response) { if (typeof request.query.file !== 'string' || typeof request.query.type !== 'string') return response.sendStatus(400); const type = request.query.type; const file = sanitize(request.query.file); if (!type || !file) { return response.sendStatus(400); } if (!(type == 'bg' || type == 'avatar')) { return response.sendStatus(400); } if (sanitize(file) !== file) { console.error('Malicious filename prevented'); return response.sendStatus(403); } if (config.disableThumbnails == true) { let folder = getOriginalFolder(type); if (folder === undefined) return response.sendStatus(400); const pathToOriginalFile = path.join(folder, file); return response.sendFile(pathToOriginalFile, { root: process.cwd() }); } const pathToCachedFile = await generateThumbnail(type, file); if (!pathToCachedFile) { return response.sendStatus(404); } return response.sendFile(pathToCachedFile, { root: process.cwd() }); }); /* OpenAI */ app.post("/getstatus_openai", jsonParser, async function (request, response_getstatus_openai) { if (!request.body) return response_getstatus_openai.sendStatus(400); let api_url; let api_key_openai; let headers; if (request.body.use_openrouter == false) { api_url = new URL(request.body.reverse_proxy || API_OPENAI).toString(); api_key_openai = request.body.reverse_proxy ? request.body.proxy_password : readSecret(SECRET_KEYS.OPENAI); headers = {}; } else { api_url = 'https://openrouter.ai/api/v1'; api_key_openai = readSecret(SECRET_KEYS.OPENROUTER); // OpenRouter needs to pass the referer: https://openrouter.ai/docs headers = { 'HTTP-Referer': request.headers.referer }; } if (!api_key_openai && !request.body.reverse_proxy) { return response_getstatus_openai.status(401).send({ error: true }); } try { const response = await fetch(api_url + "/models", { method: 'GET', headers: { "Authorization": "Bearer " + api_key_openai, ...headers, }, }); if (response.ok) { const data = await response.json(); response_getstatus_openai.send(data); if (request.body.use_openrouter) { let models = []; data.data.forEach(model => { const context_length = model.context_length; const tokens_dollar = Number(1 / (1000 * model.pricing.prompt)); const tokens_rounded = (Math.round(tokens_dollar * 1000) / 1000).toFixed(0); models[model.id] = { tokens_per_dollar: tokens_rounded + 'k', context_length: context_length, }; }); console.log('Available OpenRouter models:', models); } else { const modelIds = data?.data?.map(x => x.id)?.sort(); console.log('Available OpenAI models:', modelIds); } } else { console.log('Access Token is incorrect.'); response_getstatus_openai.send({ error: true }); } } catch (e) { console.error(e); response_getstatus_openai.send({ error: true }); } }); app.post("/openai_bias", jsonParser, async function (request, response) { if (!request.body || !Array.isArray(request.body)) return response.sendStatus(400); let result = {}; const model = getTokenizerModel(String(request.query.model || '')); // no bias for claude if (model == 'claude') { return response.send(result); } const tokenizer = getTiktokenTokenizer(model); for (const entry of request.body) { if (!entry || !entry.text) { continue; } try { const tokens = tokenizer.encode(entry.text); for (const token of tokens) { result[token] = entry.value; } } catch { console.warn('Tokenizer failed to encode:', entry.text); } } // not needed for cached tokenizers //tokenizer.free(); return response.send(result); }); app.post("/deletepreset_openai", jsonParser, function (request, response) { if (!request.body || !request.body.name) { return response.sendStatus(400); } const name = request.body.name; const pathToFile = path.join(directories.openAI_Settings, `${name}.settings`); if (fs.existsSync(pathToFile)) { fs.rmSync(pathToFile); return response.send({ ok: true }); } return response.send({ error: true }); }); function convertChatMLPrompt(messages) { const messageStrings = []; messages.forEach(m => { if (m.role === 'system' && m.name === undefined) { messageStrings.push("System: " + m.content); } else if (m.role === 'system' && m.name !== undefined) { messageStrings.push(m.name + ": " + m.content); } else { messageStrings.push(m.role + ": " + m.content); } }); return messageStrings.join("\n"); } // Prompt Conversion script taken from RisuAI by @kwaroran (GPLv3). function convertClaudePrompt(messages, addHumanPrefix, addAssistantPostfix) { // Claude doesn't support message names, so we'll just add them to the message content. for (const message of messages) { if (message.name && message.role !== "system") { message.content = message.name + ": " + message.content; delete message.name; } } let requestPrompt = messages.map((v) => { let prefix = ''; switch (v.role) { case "assistant": prefix = "\n\nAssistant: "; break case "user": prefix = "\n\nHuman: "; break case "system": // According to the Claude docs, H: and A: should be used for example conversations. if (v.name === "example_assistant") { prefix = "\n\nA: "; } else if (v.name === "example_user") { prefix = "\n\nH: "; } else { prefix = "\n\n"; } break } return prefix + v.content; }).join(''); if (addHumanPrefix) { requestPrompt = "\n\nHuman: " + requestPrompt; } if (addAssistantPostfix) { requestPrompt = requestPrompt + '\n\nAssistant: '; } return requestPrompt; } async function sendScaleRequest(request, response) { const api_url = new URL(request.body.api_url_scale).toString(); const api_key_scale = readSecret(SECRET_KEYS.SCALE); if (!api_key_scale) { return response.status(401).send({ error: true }); } const requestPrompt = convertChatMLPrompt(request.body.messages); console.log('Scale request:', requestPrompt); try { const controller = new AbortController(); request.socket.removeAllListeners('close'); request.socket.on('close', function () { controller.abort(); }); const generateResponse = await fetch(api_url, { method: "POST", body: JSON.stringify({ input: { input: requestPrompt } }), headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${api_key_scale}`, }, timeout: 0, }); if (!generateResponse.ok) { console.log(`Scale API returned error: ${generateResponse.status} ${generateResponse.statusText} ${await generateResponse.text()}`); return response.status(generateResponse.status).send({ error: true }); } const generateResponseJson = await generateResponse.json(); console.log('Scale response:', generateResponseJson); const reply = { choices: [{ "message": { "content": generateResponseJson.output, } }] }; return response.send(reply); } catch (error) { console.log(error); if (!response.headersSent) { return response.status(500).send({ error: true }); } } } app.post("/generate_altscale", jsonParser, function (request, response_generate_scale) { if (!request.body) return response_generate_scale.sendStatus(400); fetch('https://dashboard.scale.com/spellbook/api/trpc/v2.variant.run', { method: 'POST', headers: { 'Content-Type': 'application/json', 'cookie': `_jwt=${readSecret(SECRET_KEYS.SCALE_COOKIE)}`, }, body: JSON.stringify({ json: { variant: { name: 'New Variant', appId: '', taxonomy: null }, prompt: { id: '', template: '{{input}}\n', exampleVariables: {}, variablesSourceDataId: null, systemMessage: request.body.sysprompt }, modelParameters: { id: '', modelId: 'GPT4', modelType: 'OpenAi', maxTokens: request.body.max_tokens, temperature: request.body.temp, stop: "user:", suffix: null, topP: request.body.top_p, logprobs: null, logitBias: request.body.logit_bias }, inputs: [ { index: '-1', valueByName: { input: request.body.prompt } } ] }, meta: { values: { 'variant.taxonomy': ['undefined'], 'prompt.variablesSourceDataId': ['undefined'], 'modelParameters.suffix': ['undefined'], 'modelParameters.logprobs': ['undefined'], } } }) }) .then(response => response.json()) .then(data => { console.log(data.result.data.json.outputs[0]) return response_generate_scale.send({ output: data.result.data.json.outputs[0] }); }) .catch((error) => { console.error('Error:', error) return response_generate_scale.send({ error: true }) }); }); /** * @param {express.Request} request * @param {express.Response} response */ async function sendClaudeRequest(request, response) { const api_url = new URL(request.body.reverse_proxy || API_CLAUDE).toString(); const api_key_claude = request.body.reverse_proxy ? request.body.proxy_password : readSecret(SECRET_KEYS.CLAUDE); if (!api_key_claude) { return response.status(401).send({ error: true }); } try { const controller = new AbortController(); request.socket.removeAllListeners('close'); request.socket.on('close', function () { controller.abort(); }); let requestPrompt = convertClaudePrompt(request.body.messages, true, !request.body.exclude_assistant); if (request.body.assistant_prefill && !request.body.exclude_assistant) { requestPrompt += request.body.assistant_prefill; } console.log('Claude request:', requestPrompt); const stop_sequences = ["\n\nHuman:", "\n\nSystem:", "\n\nAssistant:"]; // Add custom stop sequences if (Array.isArray(request.body.stop)) { stop_sequences.push(...request.body.stop); } const generateResponse = await fetch(api_url + '/complete', { method: "POST", signal: controller.signal, body: JSON.stringify({ prompt: requestPrompt, model: request.body.model, max_tokens_to_sample: request.body.max_tokens, stop_sequences: stop_sequences, temperature: request.body.temperature, top_p: request.body.top_p, top_k: request.body.top_k, stream: request.body.stream, }), headers: { "Content-Type": "application/json", "anthropic-version": '2023-06-01', "x-api-key": api_key_claude, }, timeout: 0, }); if (request.body.stream) { // Pipe remote SSE stream to Express response generateResponse.body.pipe(response); request.socket.on('close', function () { if (generateResponse.body instanceof Readable) generateResponse.body.destroy(); // Close the remote stream response.end(); // End the Express response }); generateResponse.body.on('end', function () { console.log("Streaming request finished"); response.end(); }); } else { if (!generateResponse.ok) { console.log(`Claude API returned error: ${generateResponse.status} ${generateResponse.statusText} ${await generateResponse.text()}`); return response.status(generateResponse.status).send({ error: true }); } const generateResponseJson = await generateResponse.json(); const responseText = generateResponseJson.completion; console.log('Claude response:', responseText); // Wrap it back to OAI format const reply = { choices: [{ "message": { "content": responseText, } }] }; return response.send(reply); } } catch (error) { console.log('Error communicating with Claude: ', error); if (!response.headersSent) { return response.status(500).send({ error: true }); } } } app.post("/generate_openai", jsonParser, function (request, response_generate_openai) { if (!request.body) return response_generate_openai.status(400).send({ error: true }); if (request.body.use_claude) { return sendClaudeRequest(request, response_generate_openai); } if (request.body.use_scale) { return sendScaleRequest(request, response_generate_openai); } if (request.body.use_ai21) { return sendAI21Request(request, response_generate_openai); } let api_url; let api_key_openai; let headers; let bodyParams; if (!request.body.use_openrouter) { api_url = new URL(request.body.reverse_proxy || API_OPENAI).toString(); api_key_openai = request.body.reverse_proxy ? request.body.proxy_password : readSecret(SECRET_KEYS.OPENAI); headers = {}; bodyParams = {}; } else { api_url = 'https://openrouter.ai/api/v1'; api_key_openai = readSecret(SECRET_KEYS.OPENROUTER); // OpenRouter needs to pass the referer: https://openrouter.ai/docs headers = { 'HTTP-Referer': request.headers.referer }; bodyParams = { 'transforms': ["middle-out"] }; if (request.body.use_fallback) { bodyParams['route'] = 'fallback'; } } if (!api_key_openai && !request.body.reverse_proxy) { return response_generate_openai.status(401).send({ error: true }); } // Add custom stop sequences if (Array.isArray(request.body.stop) && request.body.stop.length > 0) { bodyParams['stop'] = request.body.stop; } const isTextCompletion = Boolean(request.body.model && (request.body.model.startsWith('text-') || request.body.model.startsWith('code-'))); const textPrompt = isTextCompletion ? convertChatMLPrompt(request.body.messages) : ''; const endpointUrl = isTextCompletion ? `${api_url}/completions` : `${api_url}/chat/completions`; const controller = new AbortController(); request.socket.removeAllListeners('close'); request.socket.on('close', function () { controller.abort(); }); /** @type {import('node-fetch').RequestInit} */ const config = { method: 'post', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + api_key_openai, ...headers, }, body: JSON.stringify({ "messages": isTextCompletion === false ? request.body.messages : undefined, "prompt": isTextCompletion === true ? textPrompt : undefined, "model": request.body.model, "temperature": request.body.temperature, "max_tokens": request.body.max_tokens, "stream": request.body.stream, "presence_penalty": request.body.presence_penalty, "frequency_penalty": request.body.frequency_penalty, "top_p": request.body.top_p, "top_k": request.body.top_k, "stop": request.body.stop, "logit_bias": request.body.logit_bias, ...bodyParams, }), signal: controller.signal, timeout: 0, }; console.log(JSON.parse(String(config.body))); makeRequest(config, response_generate_openai, request); /** * * @param {*} config * @param {express.Response} response_generate_openai * @param {express.Request} request * @param {Number} retries * @param {Number} timeout */ async function makeRequest(config, response_generate_openai, request, retries = 5, timeout = 5000) { try { const fetchResponse = await fetch(endpointUrl, config) if (fetchResponse.ok) { if (request.body.stream) { console.log('Streaming request in progress'); fetchResponse.body.pipe(response_generate_openai); fetchResponse.body.on('end', () => { console.log('Streaming request finished'); response_generate_openai.end(); }); } else { let json = await fetchResponse.json() response_generate_openai.send(json); console.log(json); console.log(json?.choices[0]?.message); } } else if (fetchResponse.status === 429 && retries > 0) { console.log(`Out of quota, retrying in ${Math.round(timeout / 1000)}s`); setTimeout(() => { makeRequest(config, response_generate_openai, request, retries - 1); }, timeout); } else { await handleErrorResponse(fetchResponse); } } catch (error) { console.log('Generation failed', error); if (!response_generate_openai.headersSent) { response_generate_openai.send({ error: true }); } else { response_generate_openai.end(); } } } async function handleErrorResponse(response) { const responseText = await response.text(); const errorData = tryParse(responseText); const statusMessages = { 400: 'Bad request', 401: 'Unauthorized', 402: 'Credit limit reached', 403: 'Forbidden', 404: 'Not found', 429: 'Too many requests', 451: 'Unavailable for legal reasons', 502: 'Bad gateway', }; const message = errorData?.error?.message || statusMessages[response.status] || 'Unknown error occurred'; const quota_error = response.status === 429 && errorData?.error?.type === 'insufficient_quota'; console.log(message); if (!response_generate_openai.headersSent) { response_generate_openai.send({ error: { message }, quota_error: quota_error }); } else if (!response_generate_openai.writableEnded) { response_generate_openai.write(response); } else { response_generate_openai.end(); } } }); app.post("/tokenize_openai", jsonParser, function (request, response_tokenize_openai) { if (!request.body) return response_tokenize_openai.sendStatus(400); let num_tokens = 0; const model = getTokenizerModel(String(request.query.model || '')); if (model == 'claude') { num_tokens = countClaudeTokens(claude_tokenizer, request.body); return response_tokenize_openai.send({ "token_count": num_tokens }); } const tokensPerName = model.includes('gpt-4') ? 1 : -1; const tokensPerMessage = model.includes('gpt-4') ? 3 : 4; const tokensPadding = 3; const tokenizer = getTiktokenTokenizer(model); for (const msg of request.body) { try { num_tokens += tokensPerMessage; for (const [key, value] of Object.entries(msg)) { num_tokens += tokenizer.encode(value).length; if (key == "name") { num_tokens += tokensPerName; } } } catch { console.warn("Error tokenizing message:", msg); } } num_tokens += tokensPadding; // not needed for cached tokenizers //tokenizer.free(); response_tokenize_openai.send({ "token_count": num_tokens }); }); async function sendAI21Request(request, response) { if (!request.body) return response.sendStatus(400); const controller = new AbortController(); console.log(request.body.messages) request.socket.removeAllListeners('close'); request.socket.on('close', function () { controller.abort(); }); const options = { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', Authorization: `Bearer ${readSecret(SECRET_KEYS.AI21)}` }, body: JSON.stringify({ numResults: 1, maxTokens: request.body.max_tokens, minTokens: 0, temperature: request.body.temperature, topP: request.body.top_p, stopSequences: request.body.stop_tokens, topKReturn: request.body.top_k, frequencyPenalty: { scale: request.body.frequency_penalty * 100, applyToWhitespaces: false, applyToPunctuations: false, applyToNumbers: false, applyToStopwords: false, applyToEmojis: false }, presencePenalty: { scale: request.body.presence_penalty, applyToWhitespaces: false, applyToPunctuations: false, applyToNumbers: false, applyToStopwords: false, applyToEmojis: false }, countPenalty: { scale: request.body.count_pen, applyToWhitespaces: false, applyToPunctuations: false, applyToNumbers: false, applyToStopwords: false, applyToEmojis: false }, prompt: request.body.messages }), signal: controller.signal, }; fetch(`https://api.ai21.com/studio/v1/${request.body.model}/complete`, options) .then(r => r.json()) .then(r => { if (r.completions === undefined) { console.log(r) } else { console.log(r.completions[0].data.text) } const reply = { choices: [{ "message": { "content": r.completions[0].data.text, } }] }; return response.send(reply) }) .catch(err => { console.error(err) return response.send({ error: true }) }); } app.post("/tokenize_ai21", jsonParser, async function (request, response_tokenize_ai21) { if (!request.body) return response_tokenize_ai21.sendStatus(400); const options = { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', Authorization: `Bearer ${readSecret(SECRET_KEYS.AI21)}` }, body: JSON.stringify({ text: request.body[0].content }) }; try { const response = await fetch('https://api.ai21.com/studio/v1/tokenize', options); const data = await response.json(); return response_tokenize_ai21.send({ "token_count": data?.tokens?.length || 0 }); } catch (err) { console.error(err); return response_tokenize_ai21.send({ "token_count": 0 }); } }); app.post("/save_preset", jsonParser, function (request, response) { const name = sanitize(request.body.name); if (!request.body.preset || !name) { return response.sendStatus(400); } const settings = getPresetSettingsByAPI(request.body.apiId); const filename = name + settings.extension; if (!settings.folder) { return response.sendStatus(400); } const fullpath = path.join(settings.folder, filename); writeFileAtomicSync(fullpath, JSON.stringify(request.body.preset, null, 4), 'utf-8'); return response.send({ name }); }); app.post("/delete_preset", jsonParser, function (request, response) { const name = sanitize(request.body.name); if (!name) { return response.sendStatus(400); } const settings = getPresetSettingsByAPI(request.body.apiId); const filename = name + settings.extension; if (!settings.folder) { return response.sendStatus(400); } const fullpath = path.join(settings.folder, filename); if (fs.existsSync(fullpath)) { fs.unlinkSync(fullpath); return response.sendStatus(200); } else { return response.sendStatus(404); } }); app.post("/savepreset_openai", jsonParser, function (request, response) { if (!request.body || typeof request.query.name !== 'string') return response.sendStatus(400); const name = sanitize(request.query.name); if (!name) return response.sendStatus(400); const filename = `${name}.settings`; const fullpath = path.join(directories.openAI_Settings, filename); writeFileAtomicSync(fullpath, JSON.stringify(request.body, null, 4), 'utf-8'); return response.send({ name }); }); function getPresetSettingsByAPI(apiId) { switch (apiId) { case 'kobold': case 'koboldhorde': return { folder: directories.koboldAI_Settings, extension: '.settings' }; case 'novel': return { folder: directories.novelAI_Settings, extension: '.settings' }; case 'textgenerationwebui': return { folder: directories.textGen_Settings, extension: '.settings' }; case 'instruct': return { folder: directories.instruct, extension: '.json' }; case 'context': return { folder: directories.context, extension: '.json' }; default: return { folder: null, extension: null }; } } function createSentencepieceEncodingHandler(getTokenizerFn) { return async function (request, response) { try { if (!request.body) { return response.sendStatus(400); } const text = request.body.text || ''; const tokenizer = getTokenizerFn(); const { ids, count } = await countSentencepieceTokens(tokenizer, text); return response.send({ ids, count }); } catch (error) { console.log(error); return response.send({ ids: [], count: 0 }); } }; } function createSentencepieceDecodingHandler(getTokenizerFn) { return async function (request, response) { try { if (!request.body) { return response.sendStatus(400); } const ids = request.body.ids || []; const tokenizer = getTokenizerFn(); const text = await tokenizer.decodeIds(ids); return response.send({ text }); } catch (error) { console.log(error); return response.send({ text: '' }); } }; } function createTiktokenEncodingHandler(modelId) { return async function (request, response) { try { if (!request.body) { return response.sendStatus(400); } const text = request.body.text || ''; const tokenizer = getTiktokenTokenizer(modelId); const tokens = Object.values(tokenizer.encode(text)); return response.send({ ids: tokens, count: tokens.length }); } catch (error) { console.log(error); return response.send({ ids: [], count: 0 }); } } } function createTiktokenDecodingHandler(modelId) { return async function (request, response) { try { if (!request.body) { return response.sendStatus(400); } const ids = request.body.ids || []; const tokenizer = getTiktokenTokenizer(modelId); const textBytes = tokenizer.decode(new Uint32Array(ids)); const text = new TextDecoder().decode(textBytes); return response.send({ text }); } catch (error) { console.log(error); return response.send({ text: '' }); } } } app.post("/tokenize_llama", jsonParser, createSentencepieceEncodingHandler(() => spp_llama)); app.post("/tokenize_nerdstash", jsonParser, createSentencepieceEncodingHandler(() => spp_nerd)); app.post("/tokenize_nerdstash_v2", jsonParser, createSentencepieceEncodingHandler(() => spp_nerd_v2)); app.post("/tokenize_gpt2", jsonParser, createTiktokenEncodingHandler('gpt2')); app.post("/decode_llama", jsonParser, createSentencepieceDecodingHandler(() => spp_llama)); app.post("/decode_nerdstash", jsonParser, createSentencepieceDecodingHandler(() => spp_nerd)); app.post("/decode_nerdstash_v2", jsonParser, createSentencepieceDecodingHandler(() => spp_nerd_v2)); app.post("/decode_gpt2", jsonParser, createTiktokenDecodingHandler('gpt2')); app.post("/tokenize_via_api", jsonParser, async function (request, response) { if (!request.body) { return response.sendStatus(400); } const text = request.body.text || ''; try { const args = { body: JSON.stringify({ "prompt": text }), headers: { "Content-Type": "application/json" } }; if (main_api == 'textgenerationwebui' && request.body.use_mancer) { args.headers = Object.assign(args.headers, get_mancer_headers()); const data = await postAsync(api_server + "/v1/token-count", args); return response.send({ count: data['results'][0]['tokens'] }); } else if (main_api == 'kobold') { const data = await postAsync(api_server + "/extra/tokencount", args); const count = data['value']; return response.send({ count: count }); } else { return response.send({ error: true }); } } catch (error) { console.log(error); return response.send({ error: true }); } }); // ** REST CLIENT ASYNC WRAPPERS ** /** * Convenience function for fetch requests (default GET) returning as JSON. * @param {string} url * @param {import('node-fetch').RequestInit} args */ async function fetchJSON(url, args = {}) { if (args.method === undefined) args.method = 'GET'; const response = await fetch(url, args); if (response.ok) { const data = await response.json(); return data; } throw response; } /** * Convenience function for fetch requests (default POST with no timeout) returning as JSON. * @param {string} url * @param {import('node-fetch').RequestInit} args */ async function postAsync(url, args) { return fetchJSON(url, { method: 'POST', timeout: 0, ...args }) } // ** END ** const tavernUrl = new URL( (cliArguments.ssl ? 'https://' : 'http://') + (listen ? '0.0.0.0' : '127.0.0.1') + (':' + server_port) ); const autorunUrl = new URL( (cliArguments.ssl ? 'https://' : 'http://') + ('127.0.0.1') + (':' + server_port) ); const setupTasks = async function () { const version = getVersion(); console.log(`SillyTavern ${version.pkgVersion}` + (version.gitBranch ? ` '${version.gitBranch}' (${version.gitRevision})` : '')); backupSettings(); migrateSecrets(); ensurePublicDirectoriesExist(); await ensureThumbnailCache(); contentManager.checkForNewContent(); cleanUploads(); await convertWebp(); [spp_llama, spp_nerd, spp_nerd_v2, claude_tokenizer] = await Promise.all([ loadSentencepieceTokenizer('src/sentencepiece/tokenizer.model'), loadSentencepieceTokenizer('src/sentencepiece/nerdstash.model'), loadSentencepieceTokenizer('src/sentencepiece/nerdstash_v2.model'), loadClaudeTokenizer('src/claude.json'), ]); await statsHelpers.loadStatsFile(directories.chats, directories.characters); // Set up event listeners for a graceful shutdown process.on('SIGINT', statsHelpers.writeStatsToFileAndExit); process.on('SIGTERM', statsHelpers.writeStatsToFileAndExit); process.on('uncaughtException', (err) => { console.error('Uncaught exception:', err); statsHelpers.writeStatsToFileAndExit(); }); setInterval(statsHelpers.saveStatsToFile, 5 * 60 * 1000); console.log('Launching...'); if (autorun) open(autorunUrl.toString()); console.log(color.green('SillyTavern is listening on: ' + tavernUrl)); if (listen) { console.log('\n0.0.0.0 means SillyTavern is listening on all network interfaces (Wi-Fi, LAN, localhost). If you want to limit it only to internal localhost (127.0.0.1), change the setting in config.conf to "listen=false". Check "access.log" file in the SillyTavern directory if you want to inspect incoming connections.\n'); } } if (listen && !config.whitelistMode && !config.basicAuthMode) { if (config.securityOverride) { console.warn(color.red("Security has been overridden. If it's not a trusted network, change the settings.")); } else { console.error(color.red('Your SillyTavern is currently unsecurely open to the public. Enable whitelisting or basic authentication.')); process.exit(1); } } if (true === cliArguments.ssl) { https.createServer( { cert: fs.readFileSync(cliArguments.certPath), key: fs.readFileSync(cliArguments.keyPath) }, app) .listen( Number(tavernUrl.port) || 443, tavernUrl.hostname, setupTasks ); } else { http.createServer(app).listen( Number(tavernUrl.port) || 80, tavernUrl.hostname, setupTasks ); } async function convertWebp() { const files = fs.readdirSync(directories.characters).filter(e => e.endsWith(".webp")); if (!files.length) { return; } console.log(`${files.length} WEBP files will be automatically converted.`); for (const file of files) { try { const source = path.join(directories.characters, file); const dest = path.join(directories.characters, path.basename(file, ".webp") + ".png"); if (fs.existsSync(dest)) { console.log(`${dest} already exists. Delete ${source} manually`); continue; } console.log(`Read... ${source}`); const data = await charaRead(source); console.log(`Convert... ${source} -> ${dest}`); await webp.dwebp(source, dest, "-o"); console.log(`Write... ${dest}`); const success = await charaWrite(dest, data, path.parse(dest).name); if (!success) { console.log(`Failure on ${source} -> ${dest}`); continue; } console.log(`Remove... ${source}`); fs.rmSync(source); } catch (err) { console.log(err); } } } function backupSettings() { const MAX_BACKUPS = 25; function generateTimestamp() { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); const hours = String(now.getHours()).padStart(2, '0'); const minutes = String(now.getMinutes()).padStart(2, '0'); const seconds = String(now.getSeconds()).padStart(2, '0'); return `${year}${month}${day}-${hours}${minutes}${seconds}`; } try { if (!fs.existsSync(directories.backups)) { fs.mkdirSync(directories.backups); } const backupFile = path.join(directories.backups, `settings_${generateTimestamp()}.json`); fs.copyFileSync(SETTINGS_FILE, backupFile); let files = fs.readdirSync(directories.backups).filter(f => f.startsWith('settings_')); if (files.length > MAX_BACKUPS) { files = files.map(f => path.join(directories.backups, f)); files.sort((a, b) => fs.statSync(a).mtimeMs - fs.statSync(b).mtimeMs); fs.rmSync(files[0]); } } catch (err) { console.log('Could not backup settings file', err); } } function ensurePublicDirectoriesExist() { for (const dir of Object.values(directories)) { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } } } const SECRETS_FILE = './secrets.json'; const SETTINGS_FILE = './public/settings.json'; const SECRET_KEYS = { HORDE: 'api_key_horde', MANCER: 'api_key_mancer', OPENAI: 'api_key_openai', NOVEL: 'api_key_novel', CLAUDE: 'api_key_claude', DEEPL: 'deepl', LIBRE: 'libre', LIBRE_URL: 'libre_url', OPENROUTER: 'api_key_openrouter', SCALE: 'api_key_scale', AI21: 'api_key_ai21', SCALE_COOKIE: 'scale_cookie', } function migrateSecrets() { if (!fs.existsSync(SETTINGS_FILE)) { console.log('Settings file does not exist'); return; } try { let modified = false; const fileContents = fs.readFileSync(SETTINGS_FILE, 'utf8'); const settings = JSON.parse(fileContents); const oaiKey = settings?.api_key_openai; const hordeKey = settings?.horde_settings?.api_key; const novelKey = settings?.api_key_novel; if (typeof oaiKey === 'string') { console.log('Migrating OpenAI key...'); writeSecret(SECRET_KEYS.OPENAI, oaiKey); delete settings.api_key_openai; modified = true; } if (typeof hordeKey === 'string') { console.log('Migrating Horde key...'); writeSecret(SECRET_KEYS.HORDE, hordeKey); delete settings.horde_settings.api_key; modified = true; } if (typeof novelKey === 'string') { console.log('Migrating Novel key...'); writeSecret(SECRET_KEYS.NOVEL, novelKey); delete settings.api_key_novel; modified = true; } if (modified) { console.log('Writing updated settings.json...'); const settingsContent = JSON.stringify(settings); writeFileAtomicSync(SETTINGS_FILE, settingsContent, "utf-8"); } } catch (error) { console.error('Could not migrate secrets file. Proceed with caution.'); } } app.post('/writesecret', jsonParser, (request, response) => { const key = request.body.key; const value = request.body.value; writeSecret(key, value); return response.send('ok'); }); app.post('/readsecretstate', jsonParser, (_, response) => { if (!fs.existsSync(SECRETS_FILE)) { return response.send({}); } try { const fileContents = fs.readFileSync(SECRETS_FILE, 'utf8'); const secrets = JSON.parse(fileContents); const state = {}; for (const key of Object.values(SECRET_KEYS)) { state[key] = !!secrets[key]; // convert to boolean } return response.send(state); } catch (error) { console.error(error); return response.send({}); } }); const ANONYMOUS_KEY = "0000000000"; app.post('/generate_horde', jsonParser, async (request, response) => { const api_key_horde = readSecret(SECRET_KEYS.HORDE) || ANONYMOUS_KEY; const url = 'https://horde.koboldai.net/api/v2/generate/text/async'; const args = { "body": JSON.stringify(request.body), "headers": { "Content-Type": "application/json", "apikey": api_key_horde, } }; if (request.header('Client-Agent') !== undefined) args.headers['Client-Agent'] = request.header('Client-Agent'); console.log(args.body); try { const data = await postAsync(url, args); return response.send(data); } catch (error) { console.log('Horde returned an error:', error.statusText); if (typeof error.text === 'function') { const message = await error.text(); console.log(message); return response.send({ error: { message } }); } else { return response.send({ error: true }); } } }); app.post('/viewsecrets', jsonParser, async (_, response) => { if (!allowKeysExposure) { console.error('secrets.json could not be viewed unless the value of allowKeysExposure in config.conf is set to true'); return response.sendStatus(403); } if (!fs.existsSync(SECRETS_FILE)) { console.error('secrets.json does not exist'); return response.sendStatus(404); } try { const fileContents = fs.readFileSync(SECRETS_FILE, 'utf-8'); const secrets = JSON.parse(fileContents); return response.send(secrets); } catch (error) { console.error(error); return response.sendStatus(500); } }); app.post('/horde_samplers', jsonParser, async (_, response) => { try { const ai_horde = getHordeClient(); const samplers = Object.values(ai_horde.ModelGenerationInputStableSamplers); response.send(samplers); } catch (error) { console.error(error); response.sendStatus(500); } }); app.post('/horde_models', jsonParser, async (_, response) => { try { const ai_horde = getHordeClient(); const models = await ai_horde.getModels(); response.send(models); } catch (error) { console.error(error); response.sendStatus(500); } }); app.post('/horde_userinfo', jsonParser, async (_, response) => { const api_key_horde = readSecret(SECRET_KEYS.HORDE); if (!api_key_horde) { return response.send({ anonymous: true }); } try { const ai_horde = getHordeClient(); const user = await ai_horde.findUser({ token: api_key_horde }); return response.send(user); } catch (error) { console.error(error); return response.sendStatus(500); } }) app.post('/horde_generateimage', jsonParser, async (request, response) => { const MAX_ATTEMPTS = 200; const CHECK_INTERVAL = 3000; const api_key_horde = readSecret(SECRET_KEYS.HORDE) || ANONYMOUS_KEY; console.log('Stable Horde request:', request.body); try { const ai_horde = getHordeClient(); const generation = await ai_horde.postAsyncImageGenerate( { prompt: `${request.body.prompt} ### ${request.body.negative_prompt}`, params: { sampler_name: request.body.sampler, hires_fix: request.body.enable_hr, // @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 use_gfpgan: request.body.restore_faces, cfg_scale: request.body.scale, steps: request.body.steps, width: request.body.width, height: request.body.height, karras: Boolean(request.body.karras), n: 1, }, r2: false, nsfw: request.body.nfsw, models: [request.body.model], }, { token: api_key_horde }); if (!generation.id) { console.error('Image generation request is not satisfyable:', generation.message || 'unknown error'); return response.sendStatus(400); } for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { await delay(CHECK_INTERVAL); const check = await ai_horde.getImageGenerationCheck(generation.id); console.log(check); if (check.done) { const result = await ai_horde.getImageGenerationStatus(generation.id); if (result.generations === undefined) return response.sendStatus(500); return response.send(result.generations[0].img); } /* if (!check.is_possible) { return response.sendStatus(503); } */ if (check.faulted) { return response.sendStatus(500); } } return response.sendStatus(504); } catch (error) { console.error(error); return response.sendStatus(500); } }); app.post('/api/novelai/generate-image', jsonParser, async (request, response) => { if (!request.body) { return response.sendStatus(400); } const key = readSecret(SECRET_KEYS.NOVEL); if (!key) { return response.sendStatus(401); } try { console.log('NAI Diffusion request:', request.body); const generateUrl = `${API_NOVELAI}/ai/generate-image`; const generateResult = await fetch(generateUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ action: 'generate', input: request.body.prompt, model: request.body.model ?? 'nai-diffusion', parameters: { negative_prompt: request.body.negative_prompt ?? '', height: request.body.height ?? 512, width: request.body.width ?? 512, scale: request.body.scale ?? 9, seed: Math.floor(Math.random() * 9999999999), sampler: request.body.sampler ?? 'k_dpmpp_2m', steps: request.body.steps ?? 28, n_samples: 1, // NAI handholding for prompts ucPreset: 0, qualityToggle: false, }, }), }); if (!generateResult.ok) { console.log('NovelAI returned an error.', generateResult.statusText); return response.sendStatus(500); } const archiveBuffer = await generateResult.arrayBuffer(); const imageBuffer = await extractFileFromZipBuffer(archiveBuffer, '.png'); const originalBase64 = imageBuffer.toString('base64'); // No upscaling if (isNaN(request.body.upscale_ratio) || request.body.upscale_ratio <= 1) { return response.send(originalBase64); } try { console.debug('Upscaling image...'); const upscaleUrl = `${API_NOVELAI}/ai/upscale`; const upscaleResult = await fetch(upscaleUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ image: originalBase64, height: request.body.height, width: request.body.width, scale: request.body.upscale_ratio, }), }); if (!upscaleResult.ok) { throw new Error('NovelAI returned an error.'); } const upscaledArchiveBuffer = await upscaleResult.arrayBuffer(); const upscaledImageBuffer = await extractFileFromZipBuffer(upscaledArchiveBuffer, '.png'); const upscaledBase64 = upscaledImageBuffer.toString('base64'); return response.send(upscaledBase64); } catch (error) { console.warn('NovelAI generated an image, but upscaling failed. Returning original image.'); return response.send(originalBase64) } } catch (error) { console.log(error); return response.sendStatus(500); } }); app.post('/api/sd/ping', jsonParser, async (request, response) => { try { const url = new URL(request.body.url); url.pathname = '/internal/ping'; const result = await fetch(url, { method: 'GET', headers: { 'Authorization': getBasicAuthHeader(request.body.auth), } }); if (!result.ok) { throw new Error('SD WebUI returned an error.'); } return response.sendStatus(200); } catch (error) { console.log(error); return response.sendStatus(500); } }); app.post('/api/sd/upscalers', jsonParser, async (request, response) => { try { async function getUpscalerModels() { const url = new URL(request.body.url); url.pathname = '/sdapi/v1/upscalers'; const result = await fetch(url, { method: 'GET', headers: { 'Authorization': getBasicAuthHeader(request.body.auth), }, }); if (!result.ok) { throw new Error('SD WebUI returned an error.'); } const data = await result.json(); const names = data.map(x => x.name); return names; } async function getLatentUpscalers() { const url = new URL(request.body.url); url.pathname = '/sdapi/v1/latent-upscale-modes'; const result = await fetch(url, { method: 'GET', headers: { 'Authorization': getBasicAuthHeader(request.body.auth), }, }); if (!result.ok) { throw new Error('SD WebUI returned an error.'); } const data = await result.json(); const names = data.map(x => x.name); return names; } const [upscalers, latentUpscalers] = await Promise.all([getUpscalerModels(), getLatentUpscalers()]); // 0 = None, then Latent Upscalers, then Upscalers upscalers.splice(1, 0, ...latentUpscalers); return response.send(upscalers); } catch (error) { console.log(error); return response.sendStatus(500); } }); app.post('/api/sd/samplers', jsonParser, async (request, response) => { try { const url = new URL(request.body.url); url.pathname = '/sdapi/v1/samplers'; const result = await fetch(url, { method: 'GET', headers: { 'Authorization': getBasicAuthHeader(request.body.auth), }, }); if (!result.ok) { throw new Error('SD WebUI returned an error.'); } const data = await result.json(); const names = data.map(x => x.name); return response.send(names); } catch (error) { console.log(error); return response.sendStatus(500); } }); app.post('/api/sd/models', jsonParser, async (request, response) => { try { const url = new URL(request.body.url); url.pathname = '/sdapi/v1/sd-models'; const result = await fetch(url, { method: 'GET', headers: { 'Authorization': getBasicAuthHeader(request.body.auth), }, }); if (!result.ok) { throw new Error('SD WebUI returned an error.'); } const data = await result.json(); const models = data.map(x => ({ value: x.title, text: x.title })); return response.send(models); } catch (error) { console.log(error); return response.sendStatus(500); } }); app.post('/api/sd/get-model', jsonParser, async (request, response) => { try { const url = new URL(request.body.url); url.pathname = '/sdapi/v1/options'; const result = await fetch(url, { method: 'GET', headers: { 'Authorization': getBasicAuthHeader(request.body.auth), }, }); const data = await result.json(); return response.send(data['sd_model_checkpoint']); } catch (error) { console.log(error); return response.sendStatus(500); } }); app.post('/api/sd/set-model', jsonParser, async (request, response) => { try { async function getProgress() { const url = new URL(request.body.url); url.pathname = '/sdapi/v1/progress'; const result = await fetch(url, { method: 'GET', headers: { 'Authorization': getBasicAuthHeader(request.body.auth), }, }); const data = await result.json(); return data; } const url = new URL(request.body.url); url.pathname = '/sdapi/v1/options'; const options = { sd_model_checkpoint: request.body.model, }; const result = await fetch(url, { method: 'POST', body: JSON.stringify(options), headers: { 'Content-Type': 'application/json', 'Authorization': getBasicAuthHeader(request.body.auth), }, }); if (!result.ok) { throw new Error('SD WebUI returned an error.'); } const MAX_ATTEMPTS = 10; const CHECK_INTERVAL = 2000; for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { const progressState = await getProgress(); const progress = progressState["progress"] const jobCount = progressState["state"]["job_count"]; if (progress == 0.0 && jobCount === 0) { break; } console.log(`Waiting for SD WebUI to finish model loading... Progress: ${progress}; Job count: ${jobCount}`); await delay(CHECK_INTERVAL); } return response.sendStatus(200); } catch (error) { console.log(error); return response.sendStatus(500); } }); app.post('/api/sd/generate', jsonParser, async (request, response) => { try { console.log('SD WebUI request:', request.body); const url = new URL(request.body.url); url.pathname = '/sdapi/v1/txt2img'; const result = await fetch(url, { method: 'POST', body: JSON.stringify(request.body), headers: { 'Content-Type': 'application/json', 'Authorization': getBasicAuthHeader(request.body.auth), }, }); if (!result.ok) { throw new Error('SD WebUI returned an error.'); } const data = await result.json(); return response.send(data); } catch (error) { console.log(error); return response.sendStatus(500); } }); app.post('/libre_translate', jsonParser, async (request, response) => { const key = readSecret(SECRET_KEYS.LIBRE); const url = readSecret(SECRET_KEYS.LIBRE_URL); const text = request.body.text; const lang = request.body.lang; if (!text || !lang) { return response.sendStatus(400); } console.log('Input text: ' + text); try { const result = await fetch(url, { method: "POST", body: JSON.stringify({ q: text, source: "auto", target: lang, format: "text", api_key: key }), headers: { "Content-Type": "application/json" } }); if (!result.ok) { return response.sendStatus(result.status); } const json = await result.json(); console.log('Translated text: ' + json.translatedText); return response.send(json.translatedText); } catch (error) { console.log("Translation error: " + error.message); return response.sendStatus(500); } }); app.post('/google_translate', jsonParser, async (request, response) => { const text = request.body.text; const lang = request.body.lang; if (!text || !lang) { return response.sendStatus(400); } console.log('Input text: ' + text); const url = generateRequestUrl(text, { to: lang }); https.get(url, (resp) => { let data = ''; resp.on('data', (chunk) => { data += chunk; }); resp.on('end', () => { const result = normaliseResponse(JSON.parse(data)); console.log('Translated text: ' + result.text); return response.send(result.text); }); }).on("error", (err) => { console.log("Translation error: " + err.message); return response.sendStatus(500); }); }); app.post('/deepl_translate', jsonParser, async (request, response) => { const key = readSecret(SECRET_KEYS.DEEPL); if (!key) { return response.sendStatus(401); } const text = request.body.text; const lang = request.body.lang; if (!text || !lang) { return response.sendStatus(400); } console.log('Input text: ' + text); const params = new URLSearchParams(); params.append('text', text); params.append('target_lang', lang); try { const result = await fetch('https://api-free.deepl.com/v2/translate', { method: 'POST', body: params, headers: { 'Accept': 'application/json', 'Authorization': `DeepL-Auth-Key ${key}`, 'Content-Type': 'application/x-www-form-urlencoded', }, timeout: 0, }); if (!result.ok) { return response.sendStatus(result.status); } const json = await result.json(); console.log('Translated text: ' + json.translations[0].text); return response.send(json.translations[0].text); } catch (error) { console.log("Translation error: " + error.message); return response.sendStatus(500); } }); app.post('/novel_tts', jsonParser, async (request, response) => { const token = readSecret(SECRET_KEYS.NOVEL); if (!token) { return response.sendStatus(401); } const text = request.body.text; const voice = request.body.voice; if (!text || !voice) { return response.sendStatus(400); } try { const url = `${API_NOVELAI}/ai/generate-voice?text=${encodeURIComponent(text)}&voice=-1&seed=${encodeURIComponent(voice)}&opus=false&version=v2`; const result = await fetch(url, { method: 'GET', headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'audio/mpeg', }, timeout: 0, }); if (!result.ok) { return response.sendStatus(result.status); } const chunks = await readAllChunks(result.body); const buffer = Buffer.concat(chunks); response.setHeader('Content-Type', 'audio/mpeg'); return response.send(buffer); } catch (error) { console.error(error); return response.sendStatus(500); } }); app.post('/delete_sprite', jsonParser, async (request, response) => { const label = request.body.label; const name = request.body.name; if (!label || !name) { return response.sendStatus(400); } try { const spritesPath = path.join(directories.characters, name); // No sprites folder exists, or not a directory if (!fs.existsSync(spritesPath) || !fs.statSync(spritesPath).isDirectory()) { return response.sendStatus(404); } const files = fs.readdirSync(spritesPath); // Remove existing sprite with the same label for (const file of files) { if (path.parse(file).name === label) { fs.rmSync(path.join(spritesPath, file)); } } return response.sendStatus(200); } catch (error) { console.error(error); return response.sendStatus(500); } }); app.post('/upload_sprite_pack', urlencodedParser, async (request, response) => { const file = request.file; const name = request.body.name; if (!file || !name) { return response.sendStatus(400); } try { const spritesPath = path.join(directories.characters, name); // Create sprites folder if it doesn't exist if (!fs.existsSync(spritesPath)) { fs.mkdirSync(spritesPath); } // Path to sprites is not a directory. This should never happen. if (!fs.statSync(spritesPath).isDirectory()) { return response.sendStatus(404); } const spritePackPath = path.join(UPLOADS_PATH, file.filename); const sprites = await getImageBuffers(spritePackPath); const files = fs.readdirSync(spritesPath); for (const [filename, buffer] of sprites) { // Remove existing sprite with the same label const existingFile = files.find(file => path.parse(file).name === path.parse(filename).name); if (existingFile) { fs.rmSync(path.join(spritesPath, existingFile)); } // Write sprite buffer to disk const pathToSprite = path.join(spritesPath, filename); writeFileAtomicSync(pathToSprite, buffer); } // Remove uploaded ZIP file fs.rmSync(spritePackPath); return response.send({ count: sprites.length }); } catch (error) { console.error(error); return response.sendStatus(500); } }); app.post('/upload_sprite', urlencodedParser, async (request, response) => { const file = request.file; const label = request.body.label; const name = request.body.name; if (!file || !label || !name) { return response.sendStatus(400); } try { const spritesPath = path.join(directories.characters, name); // Create sprites folder if it doesn't exist if (!fs.existsSync(spritesPath)) { fs.mkdirSync(spritesPath); } // Path to sprites is not a directory. This should never happen. if (!fs.statSync(spritesPath).isDirectory()) { return response.sendStatus(404); } const files = fs.readdirSync(spritesPath); // Remove existing sprite with the same label for (const file of files) { if (path.parse(file).name === label) { fs.rmSync(path.join(spritesPath, file)); } } const filename = label + path.parse(file.originalname).ext; const spritePath = path.join(UPLOADS_PATH, file.filename); const pathToFile = path.join(spritesPath, filename); // Copy uploaded file to sprites folder fs.cpSync(spritePath, pathToFile); // Remove uploaded file fs.rmSync(spritePath); return response.sendStatus(200); } catch (error) { console.error(error); return response.sendStatus(500); } }); app.post('/import_custom', jsonParser, async (request, response) => { if (!request.body.url) { return response.sendStatus(400); } try { const url = request.body.url; let result; const chubParsed = parseChubUrl(url); if (chubParsed?.type === 'character') { console.log('Downloading chub character:', chubParsed.id); result = await downloadChubCharacter(chubParsed.id); } else if (chubParsed?.type === 'lorebook') { console.log('Downloading chub lorebook:', chubParsed.id); result = await downloadChubLorebook(chubParsed.id); } else { return response.sendStatus(404); } if (result.fileType) response.set('Content-Type', result.fileType) response.set('Content-Disposition', `attachment; filename="${result.fileName}"`); response.set('X-Custom-Content-Type', chubParsed?.type); return response.send(result.buffer); } catch (error) { console.log('Importing custom content failed', error); return response.sendStatus(500); } }); /** * Extracts a file with given extension from an ArrayBuffer containing a ZIP archive. * @param {ArrayBuffer} archiveBuffer Buffer containing a ZIP archive * @param {string} fileExtension File extension to look for * @returns {Promise} Buffer containing the extracted file */ async function extractFileFromZipBuffer(archiveBuffer, fileExtension) { return await new Promise((resolve, reject) => yauzl.fromBuffer(Buffer.from(archiveBuffer), { lazyEntries: true }, (err, zipfile) => { if (err) { reject(err); } zipfile.readEntry(); zipfile.on('entry', (entry) => { if (entry.fileName.endsWith(fileExtension)) { console.log(`Extracting ${entry.fileName}`); zipfile.openReadStream(entry, (err, readStream) => { if (err) { reject(err); } else { const chunks = []; readStream.on('data', (chunk) => { chunks.push(chunk); }); readStream.on('end', () => { const buffer = Buffer.concat(chunks); resolve(buffer); zipfile.readEntry(); // Continue to the next entry }); } }); } else { zipfile.readEntry(); } }); })); } async function downloadChubLorebook(id) { const result = await fetch('https://api.chub.ai/api/lorebooks/download', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ "fullPath": id, "format": "SILLYTAVERN", }), }); if (!result.ok) { console.log(await result.text()); throw new Error('Failed to download lorebook'); } const name = id.split('/').pop(); const buffer = await result.buffer(); const fileName = `${sanitize(name)}.json`; const fileType = result.headers.get('content-type'); return { buffer, fileName, fileType }; } async function downloadChubCharacter(id) { const result = await fetch('https://api.chub.ai/api/characters/download', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ "format": "tavern", "fullPath": id, }) }); if (!result.ok) { throw new Error('Failed to download character'); } const buffer = await result.buffer(); const fileName = result.headers.get('content-disposition')?.split('filename=')[1] || `${sanitize(id)}.png`; const fileType = result.headers.get('content-type'); return { buffer, fileName, fileType }; } /** * * @param {String} str * @returns { { id: string, type: "character" | "lorebook" } | null } */ function parseChubUrl(str) { const splitStr = str.split('/'); const length = splitStr.length; if (length < 2) { return null; } let domainIndex = -1; splitStr.forEach((part, index) => { if (part === 'www.chub.ai' || part === 'chub.ai') { domainIndex = index; } }) const lastTwo = domainIndex !== -1 ? splitStr.slice(domainIndex + 1) : splitStr; const firstPart = lastTwo[0].toLowerCase(); if (firstPart === 'characters' || firstPart === 'lorebooks') { const type = firstPart === 'characters' ? 'character' : 'lorebook'; const id = type === 'character' ? lastTwo.slice(1).join('/') : lastTwo.join('/'); return { id: id, type: type }; } else if (length === 2) { return { id: lastTwo.join('/'), type: 'character' }; } return null; } function importRisuSprites(data) { try { const name = data?.data?.name; const risuData = data?.data?.extensions?.risuai; // Not a Risu AI character if (!risuData || !name) { return; } let images = []; if (Array.isArray(risuData.additionalAssets)) { images = images.concat(risuData.additionalAssets); } if (Array.isArray(risuData.emotions)) { images = images.concat(risuData.emotions); } // No sprites to import if (images.length === 0) { return; } // Create sprites folder if it doesn't exist const spritesPath = path.join(directories.characters, name); if (!fs.existsSync(spritesPath)) { fs.mkdirSync(spritesPath); } // Path to sprites is not a directory. This should never happen. if (!fs.statSync(spritesPath).isDirectory()) { return; } console.log(`RisuAI: Found ${images.length} sprites for ${name}. Writing to disk.`); const files = fs.readdirSync(spritesPath); outer: for (const [label, fileBase64] of images) { // Remove existing sprite with the same label for (const file of files) { if (path.parse(file).name === label) { console.log(`RisuAI: The sprite ${label} for ${name} already exists. Skipping.`); continue outer; } } const filename = label + '.png'; const pathToFile = path.join(spritesPath, filename); writeFileAtomicSync(pathToFile, fileBase64, { encoding: 'base64' }); } // Remove additionalAssets and emotions from data (they are now in the sprites folder) delete data.data.extensions.risuai.additionalAssets; delete data.data.extensions.risuai.emotions; } catch (error) { console.error(error); } } function writeSecret(key, value) { if (!fs.existsSync(SECRETS_FILE)) { const emptyFile = JSON.stringify({}); writeFileAtomicSync(SECRETS_FILE, emptyFile, "utf-8"); } const fileContents = fs.readFileSync(SECRETS_FILE, 'utf-8'); const secrets = JSON.parse(fileContents); secrets[key] = value; writeFileAtomicSync(SECRETS_FILE, JSON.stringify(secrets), "utf-8"); } function readSecret(key) { if (!fs.existsSync(SECRETS_FILE)) { return undefined; } const fileContents = fs.readFileSync(SECRETS_FILE, 'utf-8'); const secrets = JSON.parse(fileContents); return secrets[key]; } async function readAllChunks(readableStream) { return new Promise((resolve, reject) => { // Consume the readable stream const chunks = []; readableStream.on('data', (chunk) => { chunks.push(chunk); }); readableStream.on('end', () => { //console.log('Finished reading the stream.'); resolve(chunks); }); readableStream.on('error', (error) => { console.error('Error while reading the stream:', error); reject(); }); }); } async function getImageBuffers(zipFilePath) { return new Promise((resolve, reject) => { // Check if the zip file exists if (!fs.existsSync(zipFilePath)) { reject(new Error('File not found')); return; } const imageBuffers = []; yauzl.open(zipFilePath, { lazyEntries: true }, (err, zipfile) => { if (err) { reject(err); } else { zipfile.readEntry(); zipfile.on('entry', (entry) => { const mimeType = mime.lookup(entry.fileName); if (mimeType && mimeType.startsWith('image/') && !entry.fileName.startsWith('__MACOSX')) { console.log(`Extracting ${entry.fileName}`); zipfile.openReadStream(entry, (err, readStream) => { if (err) { reject(err); } else { const chunks = []; readStream.on('data', (chunk) => { chunks.push(chunk); }); readStream.on('end', () => { imageBuffers.push([path.parse(entry.fileName).base, Buffer.concat(chunks)]); zipfile.readEntry(); // Continue to the next entry }); } }); } else { zipfile.readEntry(); // Continue to the next entry } }); zipfile.on('end', () => { resolve(imageBuffers); }); zipfile.on('error', (err) => { reject(err); }); } }); }); } /** * This function extracts the extension information from the manifest file. * @param {string} extensionPath - The path of the extension folder * @returns {Promise} - Returns the manifest data as an object */ async function getManifest(extensionPath) { const manifestPath = path.join(extensionPath, 'manifest.json'); // Check if manifest.json exists if (!fs.existsSync(manifestPath)) { throw new Error(`Manifest file not found at ${manifestPath}`); } const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); return manifest; } async function checkIfRepoIsUpToDate(extensionPath) { // @ts-ignore - simple-git types are incorrect, this is apparently callable but no call signature const git = simpleGit(); await git.cwd(extensionPath).fetch('origin'); const currentBranch = await git.cwd(extensionPath).branch(); const currentCommitHash = await git.cwd(extensionPath).revparse(['HEAD']); const log = await git.cwd(extensionPath).log({ from: currentCommitHash, to: `origin/${currentBranch.current}`, }); // Fetch remote repository information const remotes = await git.cwd(extensionPath).getRemotes(true); return { isUpToDate: log.total === 0, remoteUrl: remotes[0].refs.fetch, // URL of the remote repository }; } /** * HTTP POST handler function to clone a git repository from a provided URL, read the extension manifest, * and return extension information and path. * * @param {Object} request - HTTP Request object, expects a JSON body with a 'url' property. * @param {Object} response - HTTP Response object used to respond to the HTTP request. * * @returns {void} */ app.post('/get_extension', jsonParser, async (request, response) => { // @ts-ignore - simple-git types are incorrect, this is apparently callable but no call signature const git = simpleGit(); if (!request.body.url) { return response.status(400).send('Bad Request: URL is required in the request body.'); } try { // make sure the third-party directory exists if (!fs.existsSync(directories.extensions + '/third-party')) { fs.mkdirSync(directories.extensions + '/third-party'); } const url = request.body.url; const extensionPath = path.join(directories.extensions, 'third-party', path.basename(url, '.git')); if (fs.existsSync(extensionPath)) { return response.status(409).send(`Directory already exists at ${extensionPath}`); } await git.clone(url, extensionPath); console.log(`Extension has been cloned at ${extensionPath}`); const { version, author, display_name } = await getManifest(extensionPath); return response.send({ version, author, display_name, extensionPath }); } catch (error) { console.log('Importing custom content failed', error); return response.status(500).send(`Server Error: ${error.message}`); } }); /** * HTTP POST handler function to pull the latest updates from a git repository * based on the extension name provided in the request body. It returns the latest commit hash, * the path of the extension, the status of the repository (whether it's up-to-date or not), * and the remote URL of the repository. * * @param {Object} request - HTTP Request object, expects a JSON body with an 'extensionName' property. * @param {Object} response - HTTP Response object used to respond to the HTTP request. * * @returns {void} */ app.post('/update_extension', jsonParser, async (request, response) => { // @ts-ignore - simple-git types are incorrect, this is apparently callable but no call signature const git = simpleGit(); if (!request.body.extensionName) { return response.status(400).send('Bad Request: extensionName is required in the request body.'); } try { const extensionName = request.body.extensionName; const extensionPath = path.join(directories.extensions, 'third-party', extensionName); if (!fs.existsSync(extensionPath)) { return response.status(404).send(`Directory does not exist at ${extensionPath}`); } const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath); const currentBranch = await git.cwd(extensionPath).branch(); if (!isUpToDate) { await git.cwd(extensionPath).pull('origin', currentBranch.current); console.log(`Extension has been updated at ${extensionPath}`); } else { console.log(`Extension is up to date at ${extensionPath}`); } await git.cwd(extensionPath).fetch('origin'); const fullCommitHash = await git.cwd(extensionPath).revparse(['HEAD']); const shortCommitHash = fullCommitHash.slice(0, 7); return response.send({ shortCommitHash, extensionPath, isUpToDate, remoteUrl }); } catch (error) { console.log('Updating custom content failed', error); return response.status(500).send(`Server Error: ${error.message}`); } }); /** * HTTP POST handler function to get the current git commit hash and branch name for a given extension. * It checks whether the repository is up-to-date with the remote, and returns the status along with * the remote URL of the repository. * * @param {Object} request - HTTP Request object, expects a JSON body with an 'extensionName' property. * @param {Object} response - HTTP Response object used to respond to the HTTP request. * * @returns {void} */ app.post('/get_extension_version', jsonParser, async (request, response) => { // @ts-ignore - simple-git types are incorrect, this is apparently callable but no call signature const git = simpleGit(); if (!request.body.extensionName) { return response.status(400).send('Bad Request: extensionName is required in the request body.'); } try { const extensionName = request.body.extensionName; const extensionPath = path.join(directories.extensions, 'third-party', extensionName); if (!fs.existsSync(extensionPath)) { return response.status(404).send(`Directory does not exist at ${extensionPath}`); } const currentBranch = await git.cwd(extensionPath).branch(); // get only the working branch const currentBranchName = currentBranch.current; await git.cwd(extensionPath).fetch('origin'); const currentCommitHash = await git.cwd(extensionPath).revparse(['HEAD']); console.log(currentBranch, currentCommitHash); const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath); return response.send({ currentBranchName, currentCommitHash, isUpToDate, remoteUrl }); } catch (error) { console.log('Getting extension version failed', error); return response.status(500).send(`Server Error: ${error.message}`); } } ); /** * HTTP POST handler function to delete a git repository based on the extension name provided in the request body. * * @param {Object} request - HTTP Request object, expects a JSON body with a 'url' property. * @param {Object} response - HTTP Response object used to respond to the HTTP request. * * @returns {void} */ app.post('/delete_extension', jsonParser, async (request, response) => { if (!request.body.extensionName) { return response.status(400).send('Bad Request: extensionName is required in the request body.'); } // Sanatize the extension name to prevent directory traversal const extensionName = sanitize(request.body.extensionName); try { const extensionPath = path.join(directories.extensions, 'third-party', extensionName); if (!fs.existsSync(extensionPath)) { return response.status(404).send(`Directory does not exist at ${extensionPath}`); } await fs.promises.rmdir(extensionPath, { recursive: true }); console.log(`Extension has been deleted at ${extensionPath}`); return response.send(`Extension has been deleted at ${extensionPath}`); } catch (error) { console.log('Deleting custom content failed', error); return response.status(500).send(`Server Error: ${error.message}`); } }); /** * HTTP POST handler function to retrieve name of all files of a given folder path. * * @param {Object} request - HTTP Request object. Require folder path in query * @param {Object} response - HTTP Response object will contain a list of file path. * * @returns {void} */ app.post('/get_assets', jsonParser, async (request, response) => { const folderPath = path.join(directories.assets); let output = {} //console.info("Checking files into",folderPath); try { if (fs.existsSync(folderPath) && fs.statSync(folderPath).isDirectory()) { const folders = fs.readdirSync(folderPath) .filter(filename => { return fs.statSync(path.join(folderPath, filename)).isDirectory(); }); for (const folder of folders) { if (folder == "temp") continue; const files = fs.readdirSync(path.join(folderPath, folder)) .filter(filename => { return filename != ".placeholder"; }); output[folder] = []; for (const file of files) { output[folder].push(path.join("assets", folder, file)); } } } } catch (err) { console.log(err); } finally { return response.send(output); } }); function checkAssetFileName(inputFilename) { // Sanitize filename if (inputFilename.indexOf('\0') !== -1) { console.debug("Bad request: poisong null bytes in filename."); return ''; } if (!/^[a-zA-Z0-9_\-\.]+$/.test(inputFilename)) { console.debug("Bad request: illegal character in filename, only alphanumeric, '_', '-' are accepted."); return ''; } if (contentManager.unsafeExtensions.some(ext => inputFilename.toLowerCase().endsWith(ext))) { console.debug("Bad request: forbidden file extension."); return ''; } if (inputFilename.startsWith('.')) { console.debug("Bad request: filename cannot start with '.'"); return ''; } return path.normalize(inputFilename).replace(/^(\.\.(\/|\\|$))+/, '');; } /** * HTTP POST handler function to download the requested asset. * * @param {Object} request - HTTP Request object, expects a url, a category and a filename. * @param {Object} response - HTTP Response only gives status. * * @returns {void} */ app.post('/asset_download', jsonParser, async (request, response) => { const url = request.body.url; const inputCategory = request.body.category; const inputFilename = sanitize(request.body.filename); const validCategories = ["bgm", "ambient"]; const fetch = require('node-fetch').default; // Check category let category = null; for (let i of validCategories) if (i == inputCategory) category = i; if (category === null) { console.debug("Bad request: unsuported asset category."); return response.sendStatus(400); } // Sanitize filename const safe_input = checkAssetFileName(inputFilename); if (safe_input == '') return response.sendStatus(400); const temp_path = path.join(directories.assets, "temp", safe_input) const file_path = path.join(directories.assets, category, safe_input) console.debug("Request received to download", url, "to", file_path); try { // Download to temp const res = await fetch(url); if (!res.ok || res.body === null) { throw new Error(`Unexpected response ${res.statusText}`); } const destination = path.resolve(temp_path); // Delete if previous download failed if (fs.existsSync(temp_path)) { fs.unlink(temp_path, (err) => { if (err) throw err; }); } const fileStream = fs.createWriteStream(destination, { flags: 'wx' }); await finished(res.body.pipe(fileStream)); // Move into asset place console.debug("Download finished, moving file from", temp_path, "to", file_path); fs.renameSync(temp_path, file_path); response.sendStatus(200); } catch (error) { console.log(error); response.sendStatus(500); } }); /** * HTTP POST handler function to delete the requested asset. * * @param {Object} request - HTTP Request object, expects a category and a filename * @param {Object} response - HTTP Response only gives stats. * * @returns {void} */ app.post('/asset_delete', jsonParser, async (request, response) => { const inputCategory = request.body.category; const inputFilename = sanitize(request.body.filename); const validCategories = ["bgm", "ambient"]; // Check category let category = null; for (let i of validCategories) if (i == inputCategory) category = i; if (category === null) { console.debug("Bad request: unsuported asset category."); return response.sendStatus(400); } // Sanitize filename const safe_input = checkAssetFileName(inputFilename); if (safe_input == '') return response.sendStatus(400); const file_path = path.join(directories.assets, category, safe_input) console.debug("Request received to delete", category, file_path); try { // Delete if previous download failed if (fs.existsSync(file_path)) { fs.unlink(file_path, (err) => { if (err) throw err; }); console.debug("Asset deleted."); } else { console.debug("Asset not found."); response.sendStatus(400); } // Move into asset place response.sendStatus(200); } catch (error) { console.log(error); response.sendStatus(500); } }); /////////////////////////////// /** * HTTP POST handler function to retrieve a character background music list. * * @param {Object} request - HTTP Request object, expects a character name in the query. * @param {Object} response - HTTP Response object will contain a list of audio file path. * * @returns {void} */ app.post('/get_character_assets_list', jsonParser, async (request, response) => { if (request.query.name === undefined) return response.sendStatus(400); const name = sanitize(request.query.name.toString()); const inputCategory = request.query.category; const validCategories = ["bgm", "ambient"] // Check category let category = null for (let i of validCategories) if (i == inputCategory) category = i if (category === null) { console.debug("Bad request: unsuported asset category."); return response.sendStatus(400); } const folderPath = path.join(directories.characters, name, category); let output = []; try { if (fs.existsSync(folderPath) && fs.statSync(folderPath).isDirectory()) { const files = fs.readdirSync(folderPath) .filter(filename => { return filename != ".placeholder"; }); for (let i of files) output.push(`/characters/${name}/${category}/${i}`); } return response.send(output); } catch (err) { console.log(err); return response.sendStatus(500); } }); // Vector storage DB require('./src/vectors').registerEndpoints(app, jsonParser);