mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
Add at-depth position for custom Prompt Manager prompts
This commit is contained in:
@ -4318,23 +4318,44 @@
|
|||||||
<h3>Edit</h3>
|
<h3>Edit</h3>
|
||||||
<div class="completion_prompt_manager_popup_entry">
|
<div class="completion_prompt_manager_popup_entry">
|
||||||
<form class="completion_prompt_manager_popup_entry_form">
|
<form class="completion_prompt_manager_popup_entry_form">
|
||||||
<div class="completion_prompt_manager_popup_entry_form_control">
|
<div class="flex-container gap10px">
|
||||||
<label for="completion_prompt_manager_popup_entry_form_name">
|
<div class="completion_prompt_manager_popup_entry_form_control flex1">
|
||||||
<span>Name</span>
|
<label for="completion_prompt_manager_popup_entry_form_name">
|
||||||
</label>
|
<span>Name</span>
|
||||||
<div class="text_muted">A name for this prompt.</div>
|
</label>
|
||||||
<input id="completion_prompt_manager_popup_entry_form_name" class="text_pole" type="text" name="name" />
|
<div class="text_muted">A name for this prompt.</div>
|
||||||
|
<input id="completion_prompt_manager_popup_entry_form_name" class="text_pole" type="text" name="name" />
|
||||||
|
</div>
|
||||||
|
<div class="completion_prompt_manager_popup_entry_form_control flex1">
|
||||||
|
<label for="completion_prompt_manager_popup_entry_form_role">
|
||||||
|
<span>Role</span>
|
||||||
|
</label>
|
||||||
|
<div class="text_muted">To whom this message will be attributed.</div>
|
||||||
|
<select id="completion_prompt_manager_popup_entry_form_role" class="text_pole" name="role">
|
||||||
|
<option value="system">System</option>
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="assistant">AI Assistant</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="completion_prompt_manager_popup_entry_form_control">
|
<div class="flex-container gap10px">
|
||||||
<label for="completion_prompt_manager_popup_entry_form_role">
|
<div class="completion_prompt_manager_popup_entry_form_control flex1">
|
||||||
<span>Role</span>
|
<label for="completion_prompt_manager_popup_entry_form_injection_position">
|
||||||
</label>
|
<span>Position</span>
|
||||||
<div class="text_muted">To whom this message will be attributed.</div>
|
</label>
|
||||||
<select id="completion_prompt_manager_popup_entry_form_role" class="text_pole" name="role">
|
<div class="text_muted">Injection position. Next to other prompts (relative) or in-chat (absolute).</div>
|
||||||
<option value="system">System</option>
|
<select id="completion_prompt_manager_popup_entry_form_injection_position" class="text_pole" name="injection_position">
|
||||||
<option value="user">User</option>
|
<option value="0">Relative</option>
|
||||||
<option value="assistant">AI Assistant</option>
|
<option value="1">Absolute</option>
|
||||||
</select>
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="completion_prompt_manager_popup_entry_form_control flex1">
|
||||||
|
<label for="completion_prompt_manager_popup_entry_form_injection_depth">
|
||||||
|
<span>Depth</span>
|
||||||
|
</label>
|
||||||
|
<div class="text_muted">Injection depth. 0 = after the last message, 1 = before the last message, etc.</div>
|
||||||
|
<input id="completion_prompt_manager_popup_entry_form_injection_depth" class="text_pole" type="number" name="injection_depth" min="0" max="999" value="4" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="completion_prompt_manager_popup_entry_form_control">
|
<div class="completion_prompt_manager_popup_entry_form_control">
|
||||||
<label for="completion_prompt_manager_popup_entry_form_prompt">
|
<label for="completion_prompt_manager_popup_entry_form_prompt">
|
||||||
|
@ -21,6 +21,16 @@ function debouncePromise(func, delay) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_DEPTH = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @enum {number}
|
||||||
|
*/
|
||||||
|
export const INJECTION_POSITION ={
|
||||||
|
RELATIVE: 0,
|
||||||
|
ABSOLUTE: 1,
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register migrations for the prompt manager when settings are loaded or an Open AI preset is loaded.
|
* Register migrations for the prompt manager when settings are loaded or an Open AI preset is loaded.
|
||||||
*/
|
*/
|
||||||
@ -60,7 +70,7 @@ const registerPromptManagerMigration = () => {
|
|||||||
* Represents a prompt.
|
* Represents a prompt.
|
||||||
*/
|
*/
|
||||||
class Prompt {
|
class Prompt {
|
||||||
identifier; role; content; name; system_prompt; position;
|
identifier; role; content; name; system_prompt; position; injection_position; injection_depth;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new Prompt instance.
|
* Create a new Prompt instance.
|
||||||
@ -72,14 +82,18 @@ class Prompt {
|
|||||||
* @param {string} param0.name - The name of the prompt.
|
* @param {string} param0.name - The name of the prompt.
|
||||||
* @param {boolean} param0.system_prompt - Indicates if the prompt is a system prompt.
|
* @param {boolean} param0.system_prompt - Indicates if the prompt is a system prompt.
|
||||||
* @param {string} param0.position - The position of the prompt in the prompt list.
|
* @param {string} param0.position - The position of the prompt in the prompt list.
|
||||||
|
* @param {number} param0.injection_position - The insert position of the prompt.
|
||||||
|
* @param {number} param0.injection_depth - The depth of the prompt in the chat.
|
||||||
*/
|
*/
|
||||||
constructor({ identifier, role, content, name, system_prompt, position } = {}) {
|
constructor({ identifier, role, content, name, system_prompt, position, injection_depth, injection_position } = {}) {
|
||||||
this.identifier = identifier;
|
this.identifier = identifier;
|
||||||
this.role = role;
|
this.role = role;
|
||||||
this.content = content;
|
this.content = content;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.system_prompt = system_prompt;
|
this.system_prompt = system_prompt;
|
||||||
this.position = position;
|
this.position = position;
|
||||||
|
this.injection_depth = injection_depth;
|
||||||
|
this.injection_position = injection_position;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -381,6 +395,8 @@ PromptManagerModule.prototype.init = function (moduleConfiguration, serviceSetti
|
|||||||
document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_name').value = prompt.name;
|
document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_name').value = prompt.name;
|
||||||
document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_role').value = 'system';
|
document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_role').value = 'system';
|
||||||
document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_prompt').value = prompt.content;
|
document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_prompt').value = prompt.content;
|
||||||
|
document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_injection_position').value = prompt.injection_position ?? 0;
|
||||||
|
document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_injection_depth').value = prompt.injection_depth ?? DEFAULT_DEPTH;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append prompt to selected character
|
// Append prompt to selected character
|
||||||
@ -673,6 +689,8 @@ PromptManagerModule.prototype.updatePromptWithPromptEditForm = function (prompt)
|
|||||||
prompt.name = document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_name').value;
|
prompt.name = document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_name').value;
|
||||||
prompt.role = document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_role').value;
|
prompt.role = document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_role').value;
|
||||||
prompt.content = document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_prompt').value;
|
prompt.content = document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_prompt').value;
|
||||||
|
prompt.injection_position = Number(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_injection_position').value);
|
||||||
|
prompt.injection_depth = Number(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_injection_depth').value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1085,10 +1103,14 @@ PromptManagerModule.prototype.loadPromptIntoEditForm = function (prompt) {
|
|||||||
const nameField = document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_name');
|
const nameField = document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_name');
|
||||||
const roleField = document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_role');
|
const roleField = document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_role');
|
||||||
const promptField = document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_prompt');
|
const promptField = document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_prompt');
|
||||||
|
const injectionPositionField = document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_injection_position');
|
||||||
|
const injectionDepthField = document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_injection_depth');
|
||||||
|
|
||||||
nameField.value = prompt.name ?? '';
|
nameField.value = prompt.name ?? '';
|
||||||
roleField.value = prompt.role ?? '';
|
roleField.value = prompt.role ?? '';
|
||||||
promptField.value = prompt.content ?? '';
|
promptField.value = prompt.content ?? '';
|
||||||
|
injectionPositionField.value = prompt.injection_position ?? INJECTION_POSITION.RELATIVE;
|
||||||
|
injectionDepthField.value = prompt.injection_depth ?? DEFAULT_DEPTH;
|
||||||
|
|
||||||
const resetPromptButton = document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_reset');
|
const resetPromptButton = document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_reset');
|
||||||
if (true === prompt.system_prompt) {
|
if (true === prompt.system_prompt) {
|
||||||
@ -1152,10 +1174,14 @@ PromptManagerModule.prototype.clearEditForm = function () {
|
|||||||
const nameField = document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_name');
|
const nameField = document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_name');
|
||||||
const roleField = document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_role');
|
const roleField = document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_role');
|
||||||
const promptField = document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_prompt');
|
const promptField = document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_prompt');
|
||||||
|
const injectionPositionField = document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_injection_position');
|
||||||
|
const injectionDepthField = document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_injection_depth');
|
||||||
|
|
||||||
nameField.value = '';
|
nameField.value = '';
|
||||||
roleField.selectedIndex = 0;
|
roleField.selectedIndex = 0;
|
||||||
promptField.value = '';
|
promptField.value = '';
|
||||||
|
injectionPositionField.selectedIndex = 0;
|
||||||
|
injectionDepthField.value = DEFAULT_DEPTH;
|
||||||
|
|
||||||
roleField.disabled = false;
|
roleField.disabled = false;
|
||||||
}
|
}
|
||||||
@ -1435,13 +1461,18 @@ PromptManagerModule.prototype.renderPromptManagerListItems = function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const encodedName = escapeHtml(prompt.name);
|
const encodedName = escapeHtml(prompt.name);
|
||||||
|
const isSystemPrompt = !prompt.marker && prompt.system_prompt && prompt.injection_position !== INJECTION_POSITION.ABSOLUTE;
|
||||||
|
const isUserPrompt = !prompt.marker && !prompt.system_prompt && prompt.injection_position !== INJECTION_POSITION.ABSOLUTE;
|
||||||
|
const isInjectionPrompt = !prompt.marker && prompt.injection_position === INJECTION_POSITION.ABSOLUTE;
|
||||||
listItemHtml += `
|
listItemHtml += `
|
||||||
<li class="${prefix}prompt_manager_prompt ${draggableClass} ${enabledClass} ${markerClass}" data-pm-identifier="${prompt.identifier}">
|
<li class="${prefix}prompt_manager_prompt ${draggableClass} ${enabledClass} ${markerClass}" data-pm-identifier="${prompt.identifier}">
|
||||||
<span class="${prefix}prompt_manager_prompt_name" data-pm-name="${encodedName}">
|
<span class="${prefix}prompt_manager_prompt_name" data-pm-name="${encodedName}">
|
||||||
${prompt.marker ? '<span class="fa-solid fa-thumb-tack" title="Marker"></span>' : ''}
|
${prompt.marker ? '<span class="fa-solid fa-thumb-tack" title="Marker"></span>' : ''}
|
||||||
${!prompt.marker && prompt.system_prompt ? '<span class="fa-solid fa-square-poll-horizontal" title="Global Prompt"></span>' : ''}
|
${isSystemPrompt ? '<span class="fa-solid fa-square-poll-horizontal" title="Global Prompt"></span>' : ''}
|
||||||
${!prompt.marker && !prompt.system_prompt ? '<span class="fa-solid fa-user" title="User Prompt"></span>' : ''}
|
${isUserPrompt ? '<span class="fa-solid fa-user" title="User Prompt"></span>' : ''}
|
||||||
|
${isInjectionPrompt ? `<span class="fa-solid fa-syringe" title="In-Chat Injection"></span>` : ''}
|
||||||
${this.isPromptInspectionAllowed(prompt) ? `<a class="prompt-manager-inspect-action">${encodedName}</a>` : encodedName}
|
${this.isPromptInspectionAllowed(prompt) ? `<a class="prompt-manager-inspect-action">${encodedName}</a>` : encodedName}
|
||||||
|
${isInjectionPrompt ? `<small class="prompt-manager-injection-depth">@ ${prompt.injection_depth}</small>` : ''}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<span class="prompt_manager_prompt_controls">
|
<span class="prompt_manager_prompt_controls">
|
||||||
|
@ -31,7 +31,8 @@ import { groups, selected_group } from "./group-chats.js";
|
|||||||
import {
|
import {
|
||||||
promptManagerDefaultPromptOrders,
|
promptManagerDefaultPromptOrders,
|
||||||
chatCompletionDefaultPrompts, Prompt,
|
chatCompletionDefaultPrompts, Prompt,
|
||||||
PromptManagerModule as PromptManager
|
PromptManagerModule as PromptManager,
|
||||||
|
INJECTION_POSITION,
|
||||||
} from "./PromptManager.js";
|
} from "./PromptManager.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -321,15 +322,6 @@ function setOpenAIMessages(chat) {
|
|||||||
openai_msgs[i] = { "role": role, "content": content, name: name };
|
openai_msgs[i] = { "role": role, "content": content, name: name };
|
||||||
j++;
|
j++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add chat injections, 100 = maximum depth of injection. (Why would you ever need more?)
|
|
||||||
for (let i = MAX_INJECTION_DEPTH; i >= 0; i--) {
|
|
||||||
const anchor = getExtensionPrompt(extension_prompt_types.IN_CHAT, i);
|
|
||||||
|
|
||||||
if (anchor && anchor.length) {
|
|
||||||
openai_msgs.splice(i, 0, { "role": 'system', 'content': anchor.trim() });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setOpenAIMessageExamples(mesExamplesArray) {
|
function setOpenAIMessageExamples(mesExamplesArray) {
|
||||||
@ -468,6 +460,34 @@ function formatWorldInfo(value) {
|
|||||||
return stringFormat(oai_settings.wi_format, value);
|
return stringFormat(oai_settings.wi_format, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function populates the injections in the conversation.
|
||||||
|
*
|
||||||
|
* @param {Prompt[]} prompts - Array containing injection prompts.
|
||||||
|
*/
|
||||||
|
function populationInjectionPrompts(prompts) {
|
||||||
|
for (let i = MAX_INJECTION_DEPTH; i >= 0; i--) {
|
||||||
|
// Get prompts for current depth
|
||||||
|
const depthPrompts = prompts.filter(prompt => prompt.injection_depth === i && prompt.content);
|
||||||
|
|
||||||
|
// Order of priority (most important go lower)
|
||||||
|
const roles = ['system', 'user', 'assistant'];
|
||||||
|
|
||||||
|
for (const role of roles) {
|
||||||
|
// Get prompts for current role
|
||||||
|
const rolePrompts = depthPrompts.filter(prompt => prompt.role === role).map(x => x.content).join('\n');
|
||||||
|
// Get extension prompt (only for system role)
|
||||||
|
const extensionPrompt = role === 'system' ? getExtensionPrompt(extension_prompt_types.IN_CHAT, i) : '';
|
||||||
|
|
||||||
|
const jointPrompt = [rolePrompts, extensionPrompt].filter(x => x).map(x => x.trim()).join('\n');
|
||||||
|
|
||||||
|
if (jointPrompt && jointPrompt.length) {
|
||||||
|
openai_msgs.splice(i, 0, { "role": role, 'content': jointPrompt });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Populates the chat history of the conversation.
|
* Populates the chat history of the conversation.
|
||||||
*
|
*
|
||||||
@ -477,7 +497,6 @@ function formatWorldInfo(value) {
|
|||||||
* @param cyclePrompt
|
* @param cyclePrompt
|
||||||
*/
|
*/
|
||||||
function populateChatHistory(prompts, chatCompletion, type = null, cyclePrompt = null) {
|
function populateChatHistory(prompts, chatCompletion, type = null, cyclePrompt = null) {
|
||||||
// Chat History
|
|
||||||
chatCompletion.add(new MessageCollection('chatHistory'), prompts.index('chatHistory'));
|
chatCompletion.add(new MessageCollection('chatHistory'), prompts.index('chatHistory'));
|
||||||
|
|
||||||
let names = (selected_group && groups.find(x => x.id === selected_group)?.members.map(member => characters.find(c => c.avatar === member)?.name).filter(Boolean).join(', ')) || '';
|
let names = (selected_group && groups.find(x => x.id === selected_group)?.members.map(member => characters.find(c => c.avatar === member)?.name).filter(Boolean).join(', ')) || '';
|
||||||
@ -646,14 +665,20 @@ function populateChatCompletion(prompts, chatCompletion, { bias, quietPrompt, ty
|
|||||||
|
|
||||||
// Add ordered system and user prompts
|
// Add ordered system and user prompts
|
||||||
const systemPrompts = ['nsfw', 'jailbreak'];
|
const systemPrompts = ['nsfw', 'jailbreak'];
|
||||||
const userPrompts = prompts.collection
|
const userRelativePrompts = prompts.collection
|
||||||
.filter((prompt) => false === prompt.system_prompt)
|
.filter((prompt) => false === prompt.system_prompt && prompt.injection_position !== INJECTION_POSITION.ABSOLUTE)
|
||||||
.reduce((acc, prompt) => {
|
.reduce((acc, prompt) => {
|
||||||
acc.push(prompt.identifier)
|
acc.push(prompt.identifier)
|
||||||
return acc;
|
return acc;
|
||||||
}, []);
|
}, []);
|
||||||
|
const userAbsolutePrompts = prompts.collection
|
||||||
|
.filter((prompt) => false === prompt.system_prompt && prompt.injection_position === INJECTION_POSITION.ABSOLUTE)
|
||||||
|
.reduce((acc, prompt) => {
|
||||||
|
acc.push(prompt)
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
|
||||||
[...systemPrompts, ...userPrompts].forEach(identifier => addToChatCompletion(identifier));
|
[...systemPrompts, ...userRelativePrompts].forEach(identifier => addToChatCompletion(identifier));
|
||||||
|
|
||||||
// Add enhance definition instruction
|
// Add enhance definition instruction
|
||||||
if (prompts.has('enhanceDefinitions')) addToChatCompletion('enhanceDefinitions');
|
if (prompts.has('enhanceDefinitions')) addToChatCompletion('enhanceDefinitions');
|
||||||
@ -697,6 +722,9 @@ function populateChatCompletion(prompts, chatCompletion, { bias, quietPrompt, ty
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add in-chat injections
|
||||||
|
populationInjectionPrompts(userAbsolutePrompts);
|
||||||
|
|
||||||
// Decide whether dialogue examples should always be added
|
// Decide whether dialogue examples should always be added
|
||||||
if (power_user.pin_examples) {
|
if (power_user.pin_examples) {
|
||||||
populateDialogueExamples(prompts, chatCompletion);
|
populateDialogueExamples(prompts, chatCompletion);
|
||||||
@ -1714,7 +1742,7 @@ class ChatCompletion {
|
|||||||
*
|
*
|
||||||
* @param {Message} message - The message to insert.
|
* @param {Message} message - The message to insert.
|
||||||
* @param {string} identifier - The identifier of the collection where to insert the message.
|
* @param {string} identifier - The identifier of the collection where to insert the message.
|
||||||
* @param {string} position - The position at which to insert the message ('start' or 'end').
|
* @param {string|number} position - The position at which to insert the message ('start' or 'end').
|
||||||
*/
|
*/
|
||||||
insert(message, identifier, position = 'end') {
|
insert(message, identifier, position = 'end') {
|
||||||
this.validateMessage(message);
|
this.validateMessage(message);
|
||||||
@ -1723,7 +1751,8 @@ class ChatCompletion {
|
|||||||
const index = this.findMessageIndex(identifier);
|
const index = this.findMessageIndex(identifier);
|
||||||
if (message.content) {
|
if (message.content) {
|
||||||
if ('start' === position) this.messages.collection[index].collection.unshift(message);
|
if ('start' === position) this.messages.collection[index].collection.unshift(message);
|
||||||
else if ('end' === position) this.messages.collection[index].collection.push(message);
|
else if ('end' === position) this.messages.collection[index].collection.push(message)
|
||||||
|
else if (typeof position === 'number') this.messages.collection[index].collection.splice(position, 0, message);
|
||||||
|
|
||||||
this.decreaseTokenBudgetBy(message.getTokens());
|
this.decreaseTokenBudgetBy(message.getTokens());
|
||||||
|
|
||||||
@ -1731,7 +1760,6 @@ class ChatCompletion {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove the last item of the collection
|
* Remove the last item of the collection
|
||||||
*
|
*
|
||||||
|
Reference in New Issue
Block a user