Merge pull request #977 from city-unit/feature/exorcism

Feature/exorcism
This commit is contained in:
Cohee 2023-08-20 12:37:56 +03:00 committed by GitHub
commit 6fb278266b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 223 additions and 51 deletions

1
.gitignore vendored
View File

@ -6,6 +6,7 @@ public/backgrounds/
public/groups/
public/group chats/
public/worlds/
public/user/
public/css/bg_load.css
public/themes/
public/OpenAI Settings/

View File

@ -2378,10 +2378,6 @@ async function Generate(type, { automatic_trigger, force_name2, resolve, reject,
abortController = new AbortController();
}
if (main_api == 'novel' && quiet_prompt) {
quiet_prompt = adjustNovelInstructionPrompt(quiet_prompt);
}
// OpenAI doesn't need instruct mode. Use OAI main prompt instead.
const isInstruct = power_user.instruct.enabled && main_api !== 'openai';
const isImpersonate = type == "impersonate";
@ -2470,6 +2466,11 @@ async function Generate(type, { automatic_trigger, force_name2, resolve, reject,
}
}
if (quiet_prompt) {
quiet_prompt = substituteParams(quiet_prompt);
quiet_prompt = main_api == 'novel' ? adjustNovelInstructionPrompt(quiet_prompt) : quiet_prompt;
}
if (true === dryRun ||
(online_status != 'no_connection' && this_chid != undefined && this_chid !== 'invalid-safety-id')) {
let textareaText;
@ -5767,7 +5768,6 @@ export async function displayPastChats() {
}
}
}
}
displayChats(''); // Display all by default
@ -7517,7 +7517,7 @@ $(document).ready(function () {
$("#character_search_bar").val("").trigger("input");
});
$(document).on("click", ".character_select", function() {
$(document).on("click", ".character_select", function () {
const id = $(this).attr("chid");
selectCharacterById(id);
});

View File

@ -1,5 +1,4 @@
import {
substituteParams,
saveSettingsDebounced,
systemUserName,
hideSwipeButtons,
@ -14,7 +13,8 @@ import {
} from "../../../script.js";
import { getApiUrl, getContext, extension_settings, doExtrasFetch, modules } from "../../extensions.js";
import { selected_group } from "../../group-chats.js";
import { stringFormat, initScrollHeight, resetScrollHeight, timestampToMoment, getCharaFilename } from "../../utils.js";
import { stringFormat, initScrollHeight, resetScrollHeight, timestampToMoment, getCharaFilename, saveBase64AsFile } from "../../utils.js";
import { humanizedDateTime } from "../../RossAscends-mods.js";
export { MODULE_NAME };
// Wraps a string into monospace font-face span
@ -512,7 +512,7 @@ function getQuietPrompt(mode, trigger) {
return trigger;
}
return substituteParams(stringFormat(extension_settings.sd.prompts[mode], trigger));
return stringFormat(extension_settings.sd.prompts[mode], trigger);
}
function processReply(str) {
@ -537,6 +537,7 @@ function processReply(str) {
return str;
}
function getRawLastMessage() {
const context = getContext();
const lastMessage = context.chat.slice(-1)[0].mes,
@ -565,6 +566,10 @@ async function generatePicture(_, trigger, message, callback) {
const quiet_prompt = getQuietPrompt(generationType, trigger);
const context = getContext();
// if context.characterId is not null, then we get context.characters[context.characterId].avatar, else we get groupId and context.groups[groupId].id
// sadly, groups is not an array, but is a dict with keys being index numbers, so we have to filter it
const characterName = context.characterId ? context.characters[context.characterId].name : context.groups[Object.keys(context.groups).filter(x => context.groups[x].id === context.groupId)[0]].id.toString();
const prevSDHeight = extension_settings.sd.height;
const prevSDWidth = extension_settings.sd.width;
const aspectRatio = extension_settings.sd.width / extension_settings.sd.height;
@ -580,8 +585,10 @@ async function generatePicture(_, trigger, message, callback) {
// Round to nearest multiple of 64
extension_settings.sd.width = Math.round(extension_settings.sd.height * 1.8 / 64) * 64;
const callbackOriginal = callback;
callback = function (prompt, base64Image) {
const imgUrl = `url(${base64Image})`;
callback = async function (prompt, base64Image) {
const imagePath = base64Image;
const imgUrl = `url('${encodeURIComponent(base64Image)}')`;
if ('forceSetBackground' in window) {
forceSetBackground(imgUrl);
} else {
@ -590,9 +597,9 @@ async function generatePicture(_, trigger, message, callback) {
}
if (typeof callbackOriginal === 'function') {
callbackOriginal(prompt, base64Image);
callbackOriginal(prompt, imagePath);
} else {
sendMessage(prompt, base64Image);
sendMessage(prompt, imagePath);
}
}
}
@ -604,7 +611,7 @@ async function generatePicture(_, trigger, message, callback) {
context.deactivateSendButtons();
hideSwipeButtons();
await sendGenerationRequest(generationType, prompt, callback);
await sendGenerationRequest(generationType, prompt, characterName, callback);
} catch (err) {
console.trace(err);
throw new Error('SD prompt text generation failed.')
@ -644,19 +651,31 @@ async function generatePrompt(quiet_prompt) {
return processReply(reply);
}
async function sendGenerationRequest(generationType, prompt, callback) {
async function sendGenerationRequest(generationType, prompt, characterName = null, callback) {
const prefix = generationType !== generationMode.BACKGROUND
? combinePrefixes(extension_settings.sd.prompt_prefix, getCharacterPrefix())
: extension_settings.sd.prompt_prefix;
if (extension_settings.sd.horde) {
await generateHordeImage(prompt, prefix, callback);
await generateHordeImage(prompt, prefix, characterName, callback);
} else {
await generateExtrasImage(prompt, prefix, callback);
await generateExtrasImage(prompt, prefix, characterName, callback);
}
}
async function generateExtrasImage(prompt, prefix, callback) {
/**
* Generates an "extras" image using a provided prompt and other settings,
* then saves the generated image and either invokes a callback or sends a message with the image.
*
* @param {string} prompt - The main instruction used to guide the image generation.
* @param {string} prefix - Additional context or prefix to guide the image generation.
* @param {string} characterName - The name used to determine the sub-directory for saving.
* @param {function} [callback] - Optional callback function invoked with the prompt and saved image.
* If not provided, `sendMessage` is called instead.
*
* @returns {Promise<void>} - A promise that resolves when the image generation and processing are complete.
*/
async function generateExtrasImage(prompt, prefix, characterName, callback) {
console.debug(extension_settings.sd);
const url = new URL(getApiUrl());
url.pathname = '/api/image';
@ -680,14 +699,28 @@ async function generateExtrasImage(prompt, prefix, callback) {
if (result.ok) {
const data = await result.json();
const base64Image = `data:image/jpeg;base64,${data.image}`;
//filename should be character name + human readable timestamp + generation mode
const filename = `${characterName}_${humanizedDateTime()}`;
const base64Image = await saveBase64AsFile(data.image, characterName, filename, "jpg");
callback ? callback(prompt, base64Image) : sendMessage(prompt, base64Image);
} else {
callPopup('Image generation has failed. Please try again.', 'text');
}
}
async function generateHordeImage(prompt, prefix, callback) {
/**
* Generates a "horde" image using the provided prompt and configuration settings,
* then saves the generated image and either invokes a callback or sends a message with the image.
*
* @param {string} prompt - The main instruction used to guide the image generation.
* @param {string} prefix - Additional context or prefix to guide the image generation.
* @param {string} characterName - The name used to determine the sub-directory for saving.
* @param {function} [callback] - Optional callback function invoked with the prompt and saved image.
* If not provided, `sendMessage` is called instead.
*
* @returns {Promise<void>} - A promise that resolves when the image generation and processing are complete.
*/
async function generateHordeImage(prompt, prefix, characterName, callback) {
const result = await fetch('/horde_generateimage', {
method: 'POST',
headers: getRequestHeaders(),
@ -709,7 +742,8 @@ async function generateHordeImage(prompt, prefix, callback) {
if (result.ok) {
const data = await result.text();
const base64Image = `data:image/webp;base64,${data}`;
const filename = `${characterName}_${humanizedDateTime()}`;
const base64Image = await saveBase64AsFile(data, characterName, filename, "webp");
callback ? callback(prompt, base64Image) : sendMessage(prompt, base64Image);
} else {
toastr.error('Image generation has failed. Please try again.');
@ -827,7 +861,7 @@ async function sdMessageButton(e) {
const message_id = $mes.attr('mesid');
const message = context.chat[message_id];
const characterName = message?.name || context.name2;
const messageText = substituteParams(message?.mes);
const messageText = message?.mes;
const hasSavedImage = message?.extra?.image && message?.extra?.title;
if ($icon.hasClass(busyClass)) {
@ -842,7 +876,7 @@ async function sdMessageButton(e) {
message.extra.title = prompt;
console.log('Regenerating an image, using existing prompt:', prompt);
await sendGenerationRequest(generationMode.FREE, prompt, saveGeneratedImage);
await sendGenerationRequest(generationMode.FREE, prompt, characterName, saveGeneratedImage);
}
else {
console.log("doing /sd raw last");

View File

@ -6,6 +6,7 @@ import {
isDataURL,
createThumbnail,
extractAllWords,
saveBase64AsFile
} from './utils.js';
import { RA_CountCharTokens, humanizedDateTime, dragElement, favsToHotswap } from "./RossAscends-mods.js";
import { loadMovingUIState, sortEntitiesList } from './power-user.js';
@ -367,12 +368,22 @@ function updateGroupAvatar(group) {
});
}
// check if isDataURLor if it's a valid local file url
function isValidImageUrl(url) {
console.trace(url);
// check if empty dict
if (Object.keys(url).length === 0) {
return false;
}
return isDataURL(url) || (url && url.startsWith("user"));
}
function getGroupAvatar(group) {
if (!group) {
return $(`<div class="avatar"><img src="${default_avatar}"></div>`);
}
if (isDataURL(group.avatar_url)) {
// if isDataURL or if it's a valid local file url
if (isValidImageUrl(group.avatar_url)) {
return $(`<div class="avatar"><img src="${group.avatar_url}"></div>`);
}
@ -1079,8 +1090,7 @@ function select_group_chats(groupId, skipAnimation) {
setMenuType(!!group ? 'group_edit' : 'group_create');
$("#group_avatar_preview").empty().append(getGroupAvatar(group));
$("#rm_group_restore_avatar").toggle(!!group && isDataURL(group.avatar_url));
$("#rm_group_chat_name").val(groupName);
$("#rm_group_restore_avatar").toggle(!!group && isValidImageUrl(group.avatar_url));
$("#rm_group_filter").val("").trigger("input");
$(`input[name="rm_group_activation_strategy"][value="${replyStrategy}"]`).prop('checked', true);
@ -1122,9 +1132,18 @@ function select_group_chats(groupId, skipAnimation) {
$("#rm_group_automode_label").hide();
}
eventSource.emit('groupSelected', {detail: {id: openGroupId, group: group}});
eventSource.emit('groupSelected', { detail: { id: openGroupId, group: group } });
}
/**
* Handles the upload and processing of a group avatar.
* The selected image is read, cropped using a popup, processed into a thumbnail,
* and then uploaded to the server.
*
* @param {Event} event - The event triggered by selecting a file input, containing the image file to upload.
*
* @returns {Promise<void>} - A promise that resolves when the processing and upload is complete.
*/
async function uploadGroupAvatar(event) {
const file = event.target.files[0];
@ -1147,16 +1166,22 @@ async function uploadGroupAvatar(event) {
return;
}
const thumbnail = await createThumbnail(croppedImage, 96, 144);
let thumbnail = await createThumbnail(croppedImage, 96, 144);
//remove data:image/whatever;base64
thumbnail = thumbnail.replace(/^data:image\/[a-z]+;base64,/, "");
let _thisGroup = groups.find((x) => x.id == openGroupId);
// filename should be group id + human readable timestamp
const filename = `${_thisGroup.id}_${humanizedDateTime()}`;
let thumbnailUrl = await saveBase64AsFile(thumbnail, openGroupId.toString(), filename, 'jpg');
if (!openGroupId) {
$('#group_avatar_preview img').attr('src', thumbnail);
$('#group_avatar_preview img').attr('src', thumbnailUrl);
$('#rm_group_restore_avatar').show();
return;
}
let _thisGroup = groups.find((x) => x.id == openGroupId);
_thisGroup.avatar_url = thumbnail;
_thisGroup.avatar_url = thumbnailUrl;
$("#group_avatar_preview").empty().append(getGroupAvatar(_thisGroup));
$("#rm_group_restore_avatar").show();
await editGroup(openGroupId, true, true);
@ -1303,7 +1328,7 @@ async function createGroup() {
body: JSON.stringify({
name: name,
members: members,
avatar_url: isDataURL(avatar_url) ? avatar_url : default_avatar,
avatar_url: isValidImageUrl(avatar_url) ? avatar_url : default_avatar,
allow_self_responses: allow_self_responses,
activation_strategy: activation_strategy,
disabled_members: [],

View File

@ -1,4 +1,5 @@
import { getContext } from "./extensions.js";
import { getRequestHeaders } from "../script.js";
export function onlyUnique(value, index, array) {
return array.indexOf(value) === index;
@ -554,6 +555,48 @@ export function extractDataFromPng(data, identifier = 'chara') {
}
}
/**
* Sends a base64 encoded image to the backend to be saved as a file.
*
* @param {string} base64Data - The base64 encoded image data.
* @param {string} characterName - The character name to determine the sub-directory for saving.
* @param {string} ext - The file extension for the image (e.g., 'jpg', 'png', 'webp').
*
* @returns {Promise<string>} - Resolves to the saved image's path on the server.
* Rejects with an error if the upload fails.
*/
export async function saveBase64AsFile(base64Data, characterName, filename = "", ext) {
// Construct the full data URL
const format = ext; // Extract the file extension (jpg, png, webp)
const dataURL = `data:image/${format};base64,${base64Data}`;
// Prepare the request body
const requestBody = {
image: dataURL,
ch_name: characterName,
filename: filename
};
// Send the data URL to your backend using fetch
const response = await fetch('/uploadimage', {
method: 'POST',
body: JSON.stringify(requestBody),
headers: {
...getRequestHeaders(),
'Content-Type': 'application/json'
},
});
// If the response is successful, get the saved image path from the server's response
if (response.ok) {
const responseData = await response.json();
return responseData.path;
} else {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to upload the image to the server');
}
}
export function createThumbnail(dataUrl, maxWidth, maxHeight) {
return new Promise((resolve, reject) => {
const img = new Image();

View File

@ -297,6 +297,8 @@ const baseRequestArgs = { headers: { "Content-Type": "application/json" } };
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/',
@ -2611,6 +2613,73 @@ app.post('/uploaduseravatar', urlencodedParser, async (request, response) => {
}
});
/**
* 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);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('/getgroups', jsonParser, (_, response) => {
const groups = [];