Merge branch 'staging' into sampler-order-ooba
This commit is contained in:
commit
40aa971d11
|
@ -9885,6 +9885,7 @@ jQuery(async function () {
|
||||||
<li>Chub characters (direct link or id)<br>Example: <tt>Anonymous/example-character</tt></li>
|
<li>Chub characters (direct link or id)<br>Example: <tt>Anonymous/example-character</tt></li>
|
||||||
<li>Chub lorebooks (direct link or id)<br>Example: <tt>lorebooks/bartleby/example-lorebook</tt></li>
|
<li>Chub lorebooks (direct link or id)<br>Example: <tt>lorebooks/bartleby/example-lorebook</tt></li>
|
||||||
<li>JanitorAI character (direct link or id)<br>Example: <tt>https://janitorai.com/characters/ddd1498a-a370-4136-b138-a8cd9461fdfe_character-aqua-the-useless-goddess</tt></li>
|
<li>JanitorAI character (direct link or id)<br>Example: <tt>https://janitorai.com/characters/ddd1498a-a370-4136-b138-a8cd9461fdfe_character-aqua-the-useless-goddess</tt></li>
|
||||||
|
<li>Pygmalion.chat character (link)<br>Example: <tt>https://pygmalion.chat/character/a7ca95a1-0c88-4e23-91b3-149db1e78ab9</tt></li>
|
||||||
<li>More coming soon...</li>
|
<li>More coming soon...</li>
|
||||||
<ul>`;
|
<ul>`;
|
||||||
const input = await callPopup(html, 'input', '', { okButton: 'Import', rows: 4 });
|
const input = await callPopup(html, 'input', '', { okButton: 'Import', rows: 4 });
|
||||||
|
|
|
@ -140,7 +140,7 @@ const getSlashCommandsHelp = parser.getHelpString.bind(parser);
|
||||||
|
|
||||||
parser.addCommand('?', helpCommandCallback, ['help'], ' – get help on macros, chat formatting and commands', true, true);
|
parser.addCommand('?', helpCommandCallback, ['help'], ' – get help on macros, chat formatting and commands', true, true);
|
||||||
parser.addCommand('name', setNameCallback, ['persona'], '<span class="monospace">(name)</span> – sets user name and persona avatar (if set)', true, true);
|
parser.addCommand('name', setNameCallback, ['persona'], '<span class="monospace">(name)</span> – sets user name and persona avatar (if set)', true, true);
|
||||||
parser.addCommand('sync', syncCallback, [], ' – syncs user name in user-attributed messages in the current chat', true, true);
|
parser.addCommand('sync', syncCallback, [], ' – syncs the user persona in user-attributed messages in the current chat', true, true);
|
||||||
parser.addCommand('lock', bindCallback, ['bind'], ' – locks/unlocks a persona (name and avatar) to the current chat', true, true);
|
parser.addCommand('lock', bindCallback, ['bind'], ' – locks/unlocks a persona (name and avatar) to the current chat', true, true);
|
||||||
parser.addCommand('bg', setBackgroundCallback, ['background'], '<span class="monospace">(filename)</span> – sets a background according to filename, partial names allowed', false, true);
|
parser.addCommand('bg', setBackgroundCallback, ['background'], '<span class="monospace">(filename)</span> – sets a background according to filename, partial names allowed', false, true);
|
||||||
parser.addCommand('sendas', sendMessageAs, [], ' – sends message as a specific character. Uses character avatar if it exists in the characters list. Example that will send "Hello, guys!" from "Chloe": <tt>/sendas name="Chloe" Hello, guys!</tt>', true, true);
|
parser.addCommand('sendas', sendMessageAs, [], ' – sends message as a specific character. Uses character avatar if it exists in the characters list. Example that will send "Hello, guys!" from "Chloe": <tt>/sendas name="Chloe" Hello, guys!</tt>', true, true);
|
||||||
|
|
|
@ -1,41 +1,80 @@
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const encode = require('png-chunks-encode');
|
||||||
const extract = require('png-chunks-extract');
|
const extract = require('png-chunks-extract');
|
||||||
const PNGtext = require('png-chunk-text');
|
const PNGtext = require('png-chunk-text');
|
||||||
|
|
||||||
const parse = async (cardUrl, format) => {
|
/**
|
||||||
|
* Writes Character metadata to a PNG image buffer.
|
||||||
|
* @param {Buffer} image PNG image buffer
|
||||||
|
* @param {string} data Character data to write
|
||||||
|
* @returns {Buffer} PNG image buffer with metadata
|
||||||
|
*/
|
||||||
|
const write = (image, data) => {
|
||||||
|
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));
|
||||||
|
const newBuffer = Buffer.from(encode(chunks));
|
||||||
|
return newBuffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads Character metadata from a PNG image buffer.
|
||||||
|
* @param {Buffer} image PNG image buffer
|
||||||
|
* @returns {string} Character data
|
||||||
|
*/
|
||||||
|
const read = (image) => {
|
||||||
|
const chunks = extract(image);
|
||||||
|
|
||||||
|
const textChunks = chunks.filter(function (chunk) {
|
||||||
|
return chunk.name === 'tEXt';
|
||||||
|
}).map(function (chunk) {
|
||||||
|
return PNGtext.decode(chunk.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (textChunks.length === 0) {
|
||||||
|
console.error('PNG metadata does not contain any text chunks.');
|
||||||
|
throw new Error('No PNG metadata.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = textChunks.findIndex((chunk) => chunk.keyword.toLowerCase() == 'chara');
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
console.error('PNG metadata does not contain any character data.');
|
||||||
|
throw new Error('No PNG metadata.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(textChunks[index].text, 'base64').toString('utf8');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a card image and returns the character metadata.
|
||||||
|
* @param {string} cardUrl Path to the card image
|
||||||
|
* @param {string} format File format
|
||||||
|
* @returns {string} Character data
|
||||||
|
*/
|
||||||
|
const parse = (cardUrl, format) => {
|
||||||
let fileFormat = format === undefined ? 'png' : format;
|
let fileFormat = format === undefined ? 'png' : format;
|
||||||
|
|
||||||
switch (fileFormat) {
|
switch (fileFormat) {
|
||||||
case 'png': {
|
case 'png': {
|
||||||
const buffer = fs.readFileSync(cardUrl);
|
const buffer = fs.readFileSync(cardUrl);
|
||||||
const chunks = extract(buffer);
|
return read(buffer);
|
||||||
|
|
||||||
const textChunks = chunks.filter(function (chunk) {
|
|
||||||
return chunk.name === 'tEXt';
|
|
||||||
}).map(function (chunk) {
|
|
||||||
return PNGtext.decode(chunk.data);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (textChunks.length === 0) {
|
|
||||||
console.error('PNG metadata does not contain any text chunks.');
|
|
||||||
throw new Error('No PNG metadata.');
|
|
||||||
}
|
|
||||||
|
|
||||||
let index = textChunks.findIndex((chunk) => chunk.keyword.toLowerCase() == 'chara');
|
|
||||||
|
|
||||||
if (index === -1) {
|
|
||||||
console.error('PNG metadata does not contain any character data.');
|
|
||||||
throw new Error('No PNG metadata.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return Buffer.from(textChunks[index].text, 'base64').toString('utf8');
|
|
||||||
}
|
}
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw new Error('Unsupported format');
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
parse: parse,
|
parse,
|
||||||
|
write,
|
||||||
|
read,
|
||||||
};
|
};
|
||||||
|
|
|
@ -7,9 +7,6 @@ const writeFileAtomicSync = require('write-file-atomic').sync;
|
||||||
const yaml = require('yaml');
|
const yaml = require('yaml');
|
||||||
const _ = require('lodash');
|
const _ = require('lodash');
|
||||||
|
|
||||||
const encode = require('png-chunks-encode');
|
|
||||||
const extract = require('png-chunks-extract');
|
|
||||||
const PNGtext = require('png-chunk-text');
|
|
||||||
const jimp = require('jimp');
|
const jimp = require('jimp');
|
||||||
|
|
||||||
const { DIRECTORIES, UPLOADS_PATH, AVATAR_WIDTH, AVATAR_HEIGHT } = require('../constants');
|
const { DIRECTORIES, UPLOADS_PATH, AVATAR_WIDTH, AVATAR_HEIGHT } = require('../constants');
|
||||||
|
@ -33,7 +30,7 @@ const characterDataCache = new Map();
|
||||||
* @param {string} input_format - 'png'
|
* @param {string} input_format - 'png'
|
||||||
* @returns {Promise<string | undefined>} - Character card data
|
* @returns {Promise<string | undefined>} - Character card data
|
||||||
*/
|
*/
|
||||||
async function charaRead(img_url, input_format) {
|
async function charaRead(img_url, input_format = 'png') {
|
||||||
const stat = fs.statSync(img_url);
|
const stat = fs.statSync(img_url);
|
||||||
const cacheKey = `${img_url}-${stat.mtimeMs}`;
|
const cacheKey = `${img_url}-${stat.mtimeMs}`;
|
||||||
if (characterDataCache.has(cacheKey)) {
|
if (characterDataCache.has(cacheKey)) {
|
||||||
|
@ -59,22 +56,12 @@ async function charaWrite(img_url, data, target_img, response = undefined, mes =
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Read the image, resize, and save it as a PNG into the buffer
|
// Read the image, resize, and save it as a PNG into the buffer
|
||||||
const image = await tryReadImage(img_url, crop);
|
const inputImage = await tryReadImage(img_url, crop);
|
||||||
|
|
||||||
// Get the chunks
|
// Get the chunks
|
||||||
const chunks = extract(image);
|
const outputImage = characterCardParser.write(inputImage, data);
|
||||||
const tEXtChunks = chunks.filter(chunk => chunk.name === 'tEXt');
|
|
||||||
|
|
||||||
// Remove all existing tEXt chunks
|
writeFileAtomicSync(DIRECTORIES.characters + target_img + '.png', outputImage);
|
||||||
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(DIRECTORIES.characters + target_img + '.png', Buffer.from(encode(chunks)));
|
|
||||||
if (response !== undefined) response.send(mes);
|
if (response !== undefined) response.send(mes);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
@ -10,6 +10,7 @@ const contentLogPath = path.join(contentDirectory, 'content.log');
|
||||||
const contentIndexPath = path.join(contentDirectory, 'index.json');
|
const contentIndexPath = path.join(contentDirectory, 'index.json');
|
||||||
const { DIRECTORIES } = require('../constants');
|
const { DIRECTORIES } = require('../constants');
|
||||||
const presetFolders = [DIRECTORIES.koboldAI_Settings, DIRECTORIES.openAI_Settings, DIRECTORIES.novelAI_Settings, DIRECTORIES.textGen_Settings];
|
const presetFolders = [DIRECTORIES.koboldAI_Settings, DIRECTORIES.openAI_Settings, DIRECTORIES.novelAI_Settings, DIRECTORIES.textGen_Settings];
|
||||||
|
const characterCardParser = require('../character-card-parser.js');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the default presets from the content directory.
|
* Gets the default presets from the content directory.
|
||||||
|
@ -219,6 +220,56 @@ async function downloadChubCharacter(id) {
|
||||||
return { buffer, fileName, fileType };
|
return { buffer, fileName, fileType };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads a character card from the Pygsite.
|
||||||
|
* @param {string} id UUID of the character
|
||||||
|
* @returns {Promise<{buffer: Buffer, fileName: string, fileType: string}>}
|
||||||
|
*/
|
||||||
|
async function downloadPygmalionCharacter(id) {
|
||||||
|
const result = await fetch(`https://server.pygmalion.chat/api/export/character/${id}/v2`);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const text = await result.text();
|
||||||
|
console.log('Pygsite returned error', result.status, text);
|
||||||
|
throw new Error('Failed to download character');
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonData = await result.json();
|
||||||
|
const characterData = jsonData?.character;
|
||||||
|
|
||||||
|
if (!characterData || typeof characterData !== 'object') {
|
||||||
|
console.error('Pygsite returned invalid character data', jsonData);
|
||||||
|
throw new Error('Failed to download character');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const avatarUrl = characterData?.data?.avatar;
|
||||||
|
|
||||||
|
if (!avatarUrl) {
|
||||||
|
console.error('Pygsite character does not have an avatar', characterData);
|
||||||
|
throw new Error('Failed to download avatar');
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarResult = await fetch(avatarUrl);
|
||||||
|
const avatarBuffer = await avatarResult.buffer();
|
||||||
|
|
||||||
|
const cardBuffer = characterCardParser.write(avatarBuffer, JSON.stringify(characterData));
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer: cardBuffer,
|
||||||
|
fileName: `${sanitize(id)}.png`,
|
||||||
|
fileType: 'image/png',
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to download avatar, using JSON instead', e);
|
||||||
|
return {
|
||||||
|
buffer: Buffer.from(JSON.stringify(jsonData)),
|
||||||
|
fileName: `${sanitize(id)}.json`,
|
||||||
|
fileType: 'application/json',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {String} str
|
* @param {String} str
|
||||||
|
@ -294,7 +345,7 @@ async function downloadJannyCharacter(uuid) {
|
||||||
* @param {String} url
|
* @param {String} url
|
||||||
* @returns {String | null } UUID of the character
|
* @returns {String | null } UUID of the character
|
||||||
*/
|
*/
|
||||||
function parseJannyUrl(url) {
|
function getUuidFromUrl(url) {
|
||||||
// Extract UUID from URL
|
// Extract UUID from URL
|
||||||
const uuidRegex = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/;
|
const uuidRegex = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/;
|
||||||
const matches = url.match(uuidRegex);
|
const matches = url.match(uuidRegex);
|
||||||
|
@ -317,8 +368,18 @@ router.post('/import', jsonParser, async (request, response) => {
|
||||||
let type;
|
let type;
|
||||||
|
|
||||||
const isJannnyContent = url.includes('janitorai');
|
const isJannnyContent = url.includes('janitorai');
|
||||||
if (isJannnyContent) {
|
const isPygmalionContent = url.includes('pygmalion.chat');
|
||||||
const uuid = parseJannyUrl(url);
|
|
||||||
|
if (isPygmalionContent) {
|
||||||
|
const uuid = getUuidFromUrl(url);
|
||||||
|
if (!uuid) {
|
||||||
|
return response.sendStatus(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
type = 'character';
|
||||||
|
result = await downloadPygmalionCharacter(uuid);
|
||||||
|
} else if (isJannnyContent) {
|
||||||
|
const uuid = getUuidFromUrl(url);
|
||||||
if (!uuid) {
|
if (!uuid) {
|
||||||
return response.sendStatus(404);
|
return response.sendStatus(404);
|
||||||
}
|
}
|
||||||
|
|
61
src/util.js
61
src/util.js
|
@ -365,7 +365,7 @@ function getImages(path) {
|
||||||
/**
|
/**
|
||||||
* Pipe a fetch() response to an Express.js Response, including status code.
|
* Pipe a fetch() response to an Express.js Response, including status code.
|
||||||
* @param {import('node-fetch').Response} from The Fetch API response to pipe from.
|
* @param {import('node-fetch').Response} from The Fetch API response to pipe from.
|
||||||
* @param {Express.Response} to The Express response to pipe to.
|
* @param {import('express').Response} to The Express response to pipe to.
|
||||||
*/
|
*/
|
||||||
function forwardFetchResponse(from, to) {
|
function forwardFetchResponse(from, to) {
|
||||||
let statusCode = from.status;
|
let statusCode = from.status;
|
||||||
|
@ -399,6 +399,64 @@ function forwardFetchResponse(from, to) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes an HTTP/2 request to the specified endpoint.
|
||||||
|
*
|
||||||
|
* @deprecated Use `node-fetch` if possible.
|
||||||
|
* @param {string} endpoint URL to make the request to
|
||||||
|
* @param {string} method HTTP method to use
|
||||||
|
* @param {string} body Request body
|
||||||
|
* @param {object} headers Request headers
|
||||||
|
* @returns {Promise<string>} Response body
|
||||||
|
*/
|
||||||
|
function makeHttp2Request(endpoint, method, body, headers) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const http2 = require('http2');
|
||||||
|
const url = new URL(endpoint);
|
||||||
|
const client = http2.connect(url.origin);
|
||||||
|
|
||||||
|
const req = client.request({
|
||||||
|
':method': method,
|
||||||
|
':path': url.pathname,
|
||||||
|
...headers,
|
||||||
|
});
|
||||||
|
req.setEncoding('utf8');
|
||||||
|
|
||||||
|
req.on('response', (headers) => {
|
||||||
|
const status = Number(headers[':status']);
|
||||||
|
|
||||||
|
if (status < 200 || status >= 300) {
|
||||||
|
reject(new Error(`Request failed with status ${status}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = '';
|
||||||
|
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('end', () => {
|
||||||
|
console.log(data);
|
||||||
|
resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
req.write(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.end();
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds YAML-serialized object to the object.
|
* Adds YAML-serialized object to the object.
|
||||||
* @param {object} obj Object
|
* @param {object} obj Object
|
||||||
|
@ -547,4 +605,5 @@ module.exports = {
|
||||||
excludeKeysByYaml,
|
excludeKeysByYaml,
|
||||||
trimV1,
|
trimV1,
|
||||||
Cache,
|
Cache,
|
||||||
|
makeHttp2Request,
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in New Issue