feat: 支持将提示词附着到已有聊天消息

This commit is contained in:
moeblack 2026-04-24 16:34:43 +08:00
parent 8d76910137
commit 8588005309
5 changed files with 172 additions and 8 deletions

View file

@ -7188,8 +7188,9 @@
<select id="completion_prompt_manager_popup_entry_form_injection_position" class="text_pole" name="injection_position">
<option data-i18n="prompt_manager_relative" value="0">Relative</option>
<option data-i18n="prompt_manager_in_chat" value="1">In-chat</option>
<option data-i18n="prompt_manager_attach_existing" value="2">Attach to existing message</option>
</select>
<div class="text_muted" data-i18n="Relative (to other prompts in prompt manager) or In-chat @ Depth.">Relative (to other prompts in prompt manager) or In-chat @ Depth.</div>
<div class="text_muted" data-i18n="Relative (to other prompts in prompt manager), In-chat @ Depth, or attached to an existing message.">Relative (to other prompts in prompt manager), In-chat @ Depth, or attached to an existing message.</div>
</div>
<div id="completion_prompt_manager_depth_block" class="completion_prompt_manager_popup_entry_form_control flex1">
<label for="completion_prompt_manager_popup_entry_form_injection_depth">
@ -7207,6 +7208,36 @@
<div class="text_muted" data-i18n="Ordered from low/top to high/bottom, and at same order: Assistant, User, System.">Ordered from low/top to high/bottom, and at same order: Assistant, User, System.</div>
</div>
</div>
<div id="completion_prompt_manager_attach_block" class="flex-container gap10px" style="display: none; margin-top: 10px;">
<div class="completion_prompt_manager_popup_entry_form_control flex1">
<label for="completion_prompt_manager_popup_entry_form_attach_role">
<span data-i18n="prompt_manager_attach_role">Attach to Role</span>
</label>
<select id="completion_prompt_manager_popup_entry_form_attach_role" class="text_pole" name="attach_role">
<option value="user">User</option>
<option value="assistant">Assistant</option>
<option value="system">System</option>
</select>
<div class="text_muted" data-i18n="Select the role of the message you want to target.">Select the role of the message you want to target.</div>
</div>
<div class="completion_prompt_manager_popup_entry_form_control flex1">
<label for="completion_prompt_manager_popup_entry_form_attach_index">
<span data-i18n="prompt_manager_attach_index">Message Index</span>
</label>
<input id="completion_prompt_manager_popup_entry_form_attach_index" class="text_pole" type="number" name="attach_index" value="1" />
<div class="text_muted" data-i18n="Positive: count from start (1 = first). Negative: count from end (-1 = last). 0 defaults to 1. Only counts messages of selected role.">Positive: count from start (1 = first). Negative: count from end (-1 = last). 0 defaults to 1. Only counts messages of selected role.</div>
</div>
<div class="completion_prompt_manager_popup_entry_form_control flex1">
<label for="completion_prompt_manager_popup_entry_form_attach_side">
<span data-i18n="prompt_manager_attach_side">Attach Side</span>
</label>
<select id="completion_prompt_manager_popup_entry_form_attach_side" class="text_pole" name="attach_side">
<option value="end">Append (End)</option>
<option value="start">Prepend (Start)</option>
</select>
<div class="text_muted" data-i18n="Prepend to the start or append to the end of the message content.">Prepend to the start or append to the end of the message content.</div>
</div>
</div>
<div class="completion_prompt_manager_popup_entry_form_control">
<div class="flex-container alignItemsCenter" data-i18n="[external_piece_text]The content of this prompt is pulled from elsewhere and cannot be edited here." external_piece_text="The content of this prompt is pulled from elsewhere and cannot be edited here.">
<div class="flex1">

View file

@ -1343,7 +1343,14 @@
"prompt_manager_position": "位置",
"prompt_manager_relative": "相对",
"prompt_manager_in_chat": "聊天中",
"Relative (to other prompts in prompt manager) or In-chat @ Depth.": "相对(相对于提示管理器中的其他提示)或在聊天中@深度。",
"prompt_manager_attach_existing": "附着到已有消息",
"Relative (to other prompts in prompt manager), In-chat @ Depth, or attached to an existing message.": "相对(相对于提示管理器中的其他提示)、在聊天中@深度,或者附着到已有的聊天消息上。",
"prompt_manager_attach_role": "附着目标角色",
"Select the role of the message you want to target.": "选择你想要附着的目标消息角色。",
"prompt_manager_attach_index": "消息顺序编号",
"Positive: count from start (1 = first). Negative: count from end (-1 = last). 0 defaults to 1. Only counts messages of selected role.": "正数从前往后数(1=第一条),负数从后往前数(-1=最后一条)。0会按1处理。只计算符合所选角色的消息。",
"prompt_manager_attach_side": "附着拼接位置",
"Prepend to the start or append to the end of the message content.": "拼接到消息内容的开头 (Prepend) 或结尾 (Append)。",
"prompt_manager_depth": "深度",
"0 = after the last message, 1 = before the last message, etc.": "“0”为在最后一条消息之后,“1”为在最后一条消息之前,等等。",
"prompt_manager_order": "排序",

View file

@ -37,6 +37,7 @@ const DEFAULT_ORDER = 100;
export const INJECTION_POSITION = {
RELATIVE: 0,
ABSOLUTE: 1,
ATTACH_EXISTING: 2,
};
/**
@ -178,8 +179,11 @@ class Prompt {
* @param {string[]} [param0.injection_trigger] - The generation type trigger for the prompt injection.
* @param {boolean} [param0.forbid_overrides] - Indicates if the prompt should not be overridden.
* @param {boolean} [param0.extension] - Prompt is added by an extension.
* @param {string} [param0.attach_role] - The role of the message to attach to.
* @param {number} [param0.attach_index] - The ordinal index of the message to attach to.
* @param {string} [param0.attach_side] - The side of the message to attach to (start/end).
*/
constructor({ identifier, role, content, name, system_prompt, position, injection_depth, injection_position, forbid_overrides, extension, injection_order, injection_trigger } = {}) {
constructor({ identifier, role, content, name, system_prompt, position, injection_depth, injection_position, forbid_overrides, extension, injection_order, injection_trigger, attach_role, attach_index, attach_side } = {}) {
this.identifier = identifier;
this.role = role;
this.content = content;
@ -192,6 +196,9 @@ class Prompt {
this.extension = extension ?? false;
this.injection_order = injection_order ?? DEFAULT_ORDER;
this.injection_trigger = injection_trigger ?? [];
this.attach_role = attach_role ?? 'user';
this.attach_index = attach_index ?? 1;
this.attach_side = attach_side ?? 'end';
}
}
@ -551,12 +558,16 @@ class PromptManager {
const injectionDepthField = /** @type {HTMLInputElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_injection_depth'));
const injectionOrderField = /** @type {HTMLInputElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_injection_order'));
const injectionTriggerField = /** @type {HTMLSelectElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_injection_trigger'));
const attachRoleField = /** @type {HTMLSelectElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_attach_role'));
const attachIndexField = /** @type {HTMLInputElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_attach_index'));
const attachSideField = /** @type {HTMLSelectElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_attach_side'));
const depthBlock = /** @type {HTMLDivElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_depth_block'));
const orderBlock = /** @type {HTMLDivElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_order_block'));
const forbidOverridesField = /** @type {HTMLInputElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_forbid_overrides'));
const forbidOverridesBlock = /** @type {HTMLDivElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_forbid_overrides_block'));
const entrySourceBlock = /** @type {HTMLDivElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_source_block'));
const entrySource = /** @type {HTMLSpanElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_source'));
const attachBlock = /** @type {HTMLDivElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_attach_block'));
nameField.value = prompt.name;
roleField.value = 'system';
@ -570,6 +581,10 @@ class PromptManager {
injectionTriggerField.dispatchEvent(new Event('change', { bubbles: true }));
depthBlock.style.visibility = prompt.injection_position === INJECTION_POSITION.ABSOLUTE ? 'visible' : 'hidden';
orderBlock.style.visibility = prompt.injection_position === INJECTION_POSITION.ABSOLUTE ? 'visible' : 'hidden';
attachBlock.style.display = prompt.injection_position === INJECTION_POSITION.ATTACH_EXISTING ? 'flex' : 'none';
attachRoleField.value = prompt.attach_role ?? 'user';
attachIndexField.value = (prompt.attach_index ?? 1).toString();
attachSideField.value = prompt.attach_side ?? 'end';
forbidOverridesField.checked = prompt.forbid_overrides ?? false;
forbidOverridesBlock.style.visibility = this.overridablePrompts.includes(prompt.identifier) ? 'visible' : 'hidden';
promptField.disabled = prompt.marker ?? false;
@ -908,6 +923,9 @@ class PromptManager {
const injectionOrderField = /** @type {HTMLInputElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_injection_order'));
const injectionTriggerField = /** @type {HTMLSelectElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_injection_trigger'));
const forbidOverridesField = /** @type {HTMLInputElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_forbid_overrides'));
const attachRoleField = /** @type {HTMLSelectElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_attach_role'));
const attachIndexField = /** @type {HTMLInputElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_attach_index'));
const attachSideField = /** @type {HTMLSelectElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_attach_side'));
prompt.name = nameField.value;
prompt.role = roleField.value;
@ -917,6 +935,9 @@ class PromptManager {
prompt.injection_order = Number(injectionOrderField.value);
prompt.injection_trigger = Array.from(injectionTriggerField.selectedOptions).map(option => option.value);
prompt.forbid_overrides = forbidOverridesField.checked;
prompt.attach_role = attachRoleField.value;
prompt.attach_index = Number(attachIndexField.value) || 1;
prompt.attach_side = attachSideField.value;
}
/**
@ -1370,6 +1391,10 @@ class PromptManager {
const forbidOverridesBlock = /** @type {HTMLDivElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_forbid_overrides_block'));
const entrySourceBlock = /** @type {HTMLDivElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_source_block'));
const entrySource = /** @type {HTMLSpanElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_source'));
const attachBlock = /** @type {HTMLDivElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_attach_block'));
const attachRoleField = /** @type {HTMLSelectElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_attach_role'));
const attachIndexField = /** @type {HTMLInputElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_attach_index'));
const attachSideField = /** @type {HTMLSelectElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_attach_side'));
const isPulledPrompt = Object.keys(this.promptSources).includes(prompt.identifier);
nameField.value = prompt.name ?? '';
@ -1385,6 +1410,10 @@ class PromptManager {
injectionTriggerField.dispatchEvent(new Event('change', { bubbles: true }));
injectionDepthBlock.style.visibility = prompt.injection_position === INJECTION_POSITION.ABSOLUTE ? 'visible' : 'hidden';
injectionOrderBlock.style.visibility = prompt.injection_position === INJECTION_POSITION.ABSOLUTE ? 'visible' : 'hidden';
attachBlock.style.display = prompt.injection_position === INJECTION_POSITION.ATTACH_EXISTING ? 'flex' : 'none';
attachRoleField.value = prompt.attach_role ?? 'user';
attachIndexField.value = (prompt.attach_index ?? 1).toString();
attachSideField.value = prompt.attach_side ?? 'end';
injectionPositionField.removeAttribute('disabled');
forbidOverridesField.checked = prompt.forbid_overrides ?? false;
forbidOverridesBlock.style.visibility = this.overridablePrompts.includes(prompt.identifier) ? 'visible' : 'hidden';
@ -1413,13 +1442,20 @@ class PromptManager {
handleInjectionPositionChange(event) {
const injectionDepthBlock = document.getElementById(this.configuration.prefix + 'prompt_manager_depth_block');
const injectionOrderBlock = document.getElementById(this.configuration.prefix + 'prompt_manager_order_block');
const attachBlock = document.getElementById(this.configuration.prefix + 'prompt_manager_attach_block');
const injectionPosition = Number(event.target.value);
if (injectionPosition === INJECTION_POSITION.ABSOLUTE) {
injectionDepthBlock.style.visibility = 'visible';
injectionOrderBlock.style.visibility = 'visible';
attachBlock.style.display = 'none';
} else if (injectionPosition === INJECTION_POSITION.ATTACH_EXISTING) {
injectionDepthBlock.style.visibility = 'hidden';
injectionOrderBlock.style.visibility = 'hidden';
attachBlock.style.display = 'flex';
} else {
injectionDepthBlock.style.visibility = 'hidden';
injectionOrderBlock.style.visibility = 'hidden';
attachBlock.style.display = 'none';
}
}
@ -1483,6 +1519,10 @@ class PromptManager {
const forbidOverridesBlock = /** @type {HTMLDivElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_forbid_overrides_block'));
const entrySourceBlock = /** @type {HTMLDivElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_source_block'));
const entrySource = /** @type {HTMLSpanElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_source'));
const attachBlock = /** @type {HTMLDivElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_attach_block'));
const attachRoleField = /** @type {HTMLSelectElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_attach_role'));
const attachIndexField = /** @type {HTMLInputElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_attach_index'));
const attachSideField = /** @type {HTMLSelectElement} */(document.getElementById(this.configuration.prefix + 'prompt_manager_popup_entry_form_attach_side'));
nameField.value = '';
roleField.selectedIndex = 0;
@ -1495,6 +1535,10 @@ class PromptManager {
injectionTriggerField.value = '';
injectionDepthBlock.style.visibility = 'unset';
injectionOrderBlock.style.visibility = 'unset';
attachBlock.style.display = 'none';
attachRoleField.value = 'user';
attachIndexField.value = '1';
attachSideField.value = 'end';
forbidOverridesBlock.style.visibility = 'unset';
forbidOverridesField.checked = false;
entrySourceBlock.style.display = 'none';
@ -1721,11 +1765,12 @@ class PromptManager {
}
const encodedName = escapeHtml(prompt.name);
const isMarkerPrompt = prompt.marker && prompt.injection_position !== INJECTION_POSITION.ABSOLUTE;
const isSystemPrompt = !prompt.marker && prompt.system_prompt && prompt.injection_position !== INJECTION_POSITION.ABSOLUTE && !prompt.forbid_overrides;
const isImportantPrompt = !prompt.marker && prompt.system_prompt && prompt.injection_position !== INJECTION_POSITION.ABSOLUTE && prompt.forbid_overrides;
const isUserPrompt = !prompt.marker && !prompt.system_prompt && prompt.injection_position !== INJECTION_POSITION.ABSOLUTE;
const isMarkerPrompt = prompt.marker && prompt.injection_position === INJECTION_POSITION.RELATIVE;
const isSystemPrompt = !prompt.marker && prompt.system_prompt && prompt.injection_position === INJECTION_POSITION.RELATIVE && !prompt.forbid_overrides;
const isImportantPrompt = !prompt.marker && prompt.system_prompt && prompt.injection_position === INJECTION_POSITION.RELATIVE && prompt.forbid_overrides;
const isUserPrompt = !prompt.marker && !prompt.system_prompt && prompt.injection_position === INJECTION_POSITION.RELATIVE;
const isInjectionPrompt = prompt.injection_position === INJECTION_POSITION.ABSOLUTE;
const isAttachedPrompt = prompt.injection_position === INJECTION_POSITION.ATTACH_EXISTING;
const isOverriddenPrompt = Array.isArray(this.overriddenPrompts) && this.overriddenPrompts.includes(prompt.identifier);
const importantClass = isImportantPrompt ? `${prefix}prompt_manager_important` : '';
const iconLookup = prompt.role === 'system' && (prompt.marker || prompt.system_prompt) ? '' : prompt.role;
@ -1747,9 +1792,13 @@ class PromptManager {
${isImportantPrompt ? '<span class="fa-fw fa-solid fa-star" title="Important Prompt"></span>' : ''}
${isUserPrompt ? '<span class="fa-fw fa-solid fa-asterisk" title="Preset Prompt"></span>' : ''}
${isInjectionPrompt ? '<span class="fa-fw fa-solid fa-syringe" title="In-Chat Injection"></span>' : ''}
${isAttachedPrompt ? '<span class="fa-fw fa-solid fa-paperclip" title="Attached to Existing Message"></span>' : ''}
${this.isPromptInspectionAllowed(prompt) ? `<a title="${encodedName}" class="prompt-manager-inspect-action">${encodedName}</a>` : `<span title="${encodedName}">${encodedName}</span>`}
${roleIcon ? `<span data-role="${escapeHtml(prompt.role)}" class="fa-xs fa-solid ${roleIcon}" title="${roleTitle}"></span>` : ''}
${isInjectionPrompt ? `<small class="prompt-manager-injection-depth">@ ${escapeHtml(prompt.injection_depth.toString())}</small>` : ''}
${isAttachedPrompt
? `<small class="prompt-manager-injection-depth">${escapeHtml(prompt.attach_side === 'start' ? 'Prepend' : 'Append')} to ${escapeHtml(prompt.attach_role)} ${escapeHtml(String(prompt.attach_index))}</small>`
: ''}
${isOverriddenPrompt ? '<small class="fa-solid fa-address-card prompt-manager-overridden" title="Pulled from a character card"></small>' : ''}
</span>
<span>

View file

@ -774,6 +774,56 @@ export function formatWorldInfo(value, { wiFormat = null } = {}) {
return stringFormat(format, value);
}
/**
* Apply attach-existing prompts directly onto the raw chat history messages.
* Why: the feature is defined against existing chat turns, not the final prompt stack.
* How: we resolve the target within the original latest-first message array by role and
* chronological index, then mutate that message content before any in-chat injection runs.
* Purpose: make "first user message end" and the generalized attach semantics match the
* original requirement instead of counting synthetic prompt-manager messages.
*
* @param {Prompt[]} attachedPrompts - Prompts configured for ATTACH_EXISTING.
* @param {Object[]} messages - Raw chat history messages in latest-first order.
*/
function applyAttachedPromptsToMessages(attachedPrompts, messages) {
if (!Array.isArray(attachedPrompts) || !attachedPrompts.length || !Array.isArray(messages) || !messages.length) {
return;
}
for (const attachPrompt of attachedPrompts) {
const attachContent = String(attachPrompt?.content ?? '').trim();
const attachRole = String(attachPrompt?.attach_role ?? '').trim();
const attachSide = attachPrompt?.attach_side === 'start' ? 'start' : 'end';
const requestedIndex = Number(attachPrompt?.attach_index) || 1;
if (!attachContent || !attachRole) {
continue;
}
const chronologicalRoleMatches = messages
.filter(message => message && !message.injected && message.role === attachRole)
.slice()
.reverse();
if (!chronologicalRoleMatches.length) {
continue;
}
const normalizedIndex = requestedIndex === 0 ? 1 : requestedIndex;
const targetIndex = normalizedIndex > 0 ? normalizedIndex - 1 : chronologicalRoleMatches.length + normalizedIndex;
const targetMessage = chronologicalRoleMatches[targetIndex];
if (!targetMessage) {
continue;
}
const currentContent = String(targetMessage.content ?? '');
targetMessage.content = attachSide === 'start'
? [attachContent, currentContent].filter(Boolean).join('\n\n')
: [currentContent, attachContent].filter(Boolean).join('\n\n');
}
}
/**
* This function populates the injections in the conversation.
*
@ -1243,7 +1293,7 @@ async function populateChatCompletion(prompts, chatCompletion, { bias, quietProm
// Add ordered system and user prompts
const systemPrompts = ['nsfw', 'jailbreak'];
const userRelativePrompts = prompts.collection
.filter((prompt) => false === prompt.system_prompt && prompt.injection_position !== INJECTION_POSITION.ABSOLUTE)
.filter((prompt) => false === prompt.system_prompt && prompt.injection_position !== INJECTION_POSITION.ABSOLUTE && prompt.injection_position !== INJECTION_POSITION.ATTACH_EXISTING)
.reduce((acc, prompt) => {
acc.push(prompt.identifier);
return acc;
@ -1254,6 +1304,8 @@ async function populateChatCompletion(prompts, chatCompletion, { bias, quietProm
acc.push(prompt);
return acc;
}, []);
const attachedPrompts = prompts.collection
.filter((prompt) => prompt.injection_position === INJECTION_POSITION.ATTACH_EXISTING);
for (const identifier of [...systemPrompts, ...userRelativePrompts]) {
await addToChatCompletion(identifier);
@ -1318,6 +1370,12 @@ async function populateChatCompletion(prompts, chatCompletion, { bias, quietProm
chatCompletion.reserveBudget(toolTokens);
}
// Apply attach-existing prompts before later prompt assembly mutates the history.
// This keeps indexing anchored to real chat turns instead of synthetic completion rows.
if (attachedPrompts.length > 0) {
applyAttachedPromptsToMessages(attachedPrompts, messages);
}
// Displace the message to be continued from its original position before performing in-chat injections
// In case if it is an assistant message, we want to prepend the users assistant prefill on the message
if (type === 'continue' && oai_settings.continue_prefill && messages.length) {

View file

@ -0,0 +1,19 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
function readProjectFile(relativePath) {
return readFile(path.join(REPO_ROOT, relativePath), 'utf8');
}
test('attach existing message logic runs on raw chat history before in-chat injections', async () => {
const openaiSource = await readProjectFile('src/scripts/openai.js');
assert.match(openaiSource, /function applyAttachedPromptsToMessages\(attachedPrompts, messages\)/);
assert.match(openaiSource, /applyAttachedPromptsToMessages\(attachedPrompts, messages\);[\s\S]*messages = await populationInjectionPrompts\(absolutePrompts, messages\);/);
assert.doesNotMatch(openaiSource, /chatCompletion\.getMessages\(\)[\s\S]*attach/);
});