From afbbde7ebf4e707ed18918c326215f9ab49511d0 Mon Sep 17 00:00:00 2001 From: 1432647 <1432647@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:19:51 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86=EF=BC=8C?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=90=8E=E5=8F=B0=E6=8A=A5=E9=94=99=E6=98=BE?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/endpoints/backends/chat-completions.js | 2 +- src/endpoints/backends/kobold.js | 3 +- src/endpoints/backends/luker-generation.js | 41 +++++++++++++--------- src/endpoints/backends/text-completions.js | 35 ++++++++++-------- src/endpoints/novelai.js | 2 +- src/util.js | 8 ++++- 6 files changed, 54 insertions(+), 37 deletions(-) diff --git a/src/endpoints/backends/chat-completions.js b/src/endpoints/backends/chat-completions.js index 88e21c6c4..2afbcb5c0 100644 --- a/src/endpoints/backends/chat-completions.js +++ b/src/endpoints/backends/chat-completions.js @@ -253,7 +253,7 @@ async function forwardStreamingResponseWithJob(request, response, fetchResponse) if (job) { return await forwardStreamingWithGenerationJob(fetchResponse, response, request, job, { modelName: request.body?.model }); } - return forwardFetchResponse(fetchResponse, response); + return forwardFetchResponse(fetchResponse, response, { jsonErrorResponse: true }); } async function finalizePayloadWithJob(request, response, payload, rawApiResponse) { diff --git a/src/endpoints/backends/kobold.js b/src/endpoints/backends/kobold.js index 0a205b586..ca4e06d69 100644 --- a/src/endpoints/backends/kobold.js +++ b/src/endpoints/backends/kobold.js @@ -118,8 +118,7 @@ router.post('/generate', async function (request, response_generate) { return await forwardStreamingWithGenerationJob(fetchResponse, response_generate, request, lukerGenerationJob, { modelName: request.body.model }); } // Pipe remote SSE stream to Express response - forwardFetchResponse(fetchResponse, response_generate); - return; + return forwardFetchResponse(fetchResponse, response_generate, { jsonErrorResponse: true }); } else { if (!fetchResponse.ok) { const errorText = await fetchResponse.text(); diff --git a/src/endpoints/backends/luker-generation.js b/src/endpoints/backends/luker-generation.js index 00a5534ea..a6f8db191 100644 --- a/src/endpoints/backends/luker-generation.js +++ b/src/endpoints/backends/luker-generation.js @@ -6,7 +6,7 @@ import sanitize from 'sanitize-filename'; import { CHAT_COMPLETION_SOURCES } from '../../constants.js'; import { appendMessagesToChatFile } from '../chats.js'; -import { getConfigValue } from '../../util.js'; +import { getConfigValue, tryParse } from '../../util.js'; import { completeInspectionFromStream, failInspection, @@ -733,6 +733,29 @@ export function acknowledgeGenerationJobsForPersistTarget(request, persistTarget export async function forwardStreamingWithGenerationJob(fetchResponse, response, request, job, options = {}) { const modelName = String(options.modelName || request.body?.model || ''); + let clientClosed = false; + response.socket?.on('close', () => { + clientClosed = true; + }); + if (!response.headersSent) { + response.setHeader('x-luker-generation-id', job.id); + } + if (!fetchResponse.ok) { + const errorText = await fetchResponse.text().catch(() => ''); + const errorMessage = `${fetchResponse.status} ${fetchResponse.statusText}`.trim(); + console.warn(`Streaming API returned error: ${errorMessage}${errorText ? ` ${errorText}` : ''}`); + failGenerationJob(job, errorMessage || errorText || 'Streaming request failed'); + failInspection(request, errorMessage || errorText || 'Streaming request failed', fetchResponse.status); + if (!clientClosed && !response.writableEnded) { + if (!response.headersSent) { + const errorJson = tryParse(errorText) ?? { error: true }; + return response.status(500).send(errorJson); + } + response.end(errorText || ''); + } + return; + } + let statusCode = fetchResponse.status; if (statusCode === 401) { statusCode = 400; @@ -740,7 +763,6 @@ export async function forwardStreamingWithGenerationJob(fetchResponse, response, response.statusCode = statusCode; response.statusMessage = fetchResponse.statusText; - response.setHeader('x-luker-generation-id', job.id); const contentType = fetchResponse.headers.get('content-type'); if (contentType) { response.setHeader('content-type', contentType); @@ -753,21 +775,6 @@ export async function forwardStreamingWithGenerationJob(fetchResponse, response, response.flushHeaders(); } - let clientClosed = false; - response.socket?.on('close', () => { - clientClosed = true; - }); - - if (!fetchResponse.ok) { - const errorText = await fetchResponse.text().catch(() => ''); - failGenerationJob(job, `${fetchResponse.status} ${fetchResponse.statusText}`.trim()); - failInspection(request, `${fetchResponse.status} ${fetchResponse.statusText}`.trim(), fetchResponse.status); - if (!clientClosed && !response.writableEnded) { - response.end(errorText || ''); - } - return; - } - // Preserve the original byte stream for the client and decode incrementally only for SSE bookkeeping. let buffer = ''; const decoder = new TextDecoder('utf-8'); diff --git a/src/endpoints/backends/text-completions.js b/src/endpoints/backends/text-completions.js index 26d566f46..b9d23135e 100644 --- a/src/endpoints/backends/text-completions.js +++ b/src/endpoints/backends/text-completions.js @@ -12,7 +12,7 @@ import { FEATHERLESS_KEYS, OPENAI_KEYS, } from '../../constants.js'; -import { forwardFetchResponse, trimV1, getConfigValue } from '../../util.js'; +import { forwardFetchResponse, trimV1, getConfigValue, tryParse } from '../../util.js'; import { setAdditionalHeaders } from '../../additional-headers.js'; import { createHash } from 'node:crypto'; import { @@ -37,33 +37,38 @@ export const router = express.Router(); */ async function parseOllamaStream(jsonStream, request, response, job = null) { try { - let statusCode = jsonStream.status; - if (statusCode === 401) { - statusCode = 400; - } - response.statusCode = statusCode; - response.statusMessage = jsonStream.statusText; - response.setHeader('content-type', 'text/event-stream; charset=utf-8'); - if (job) { - response.setHeader('x-luker-generation-id', job.id); - } - let clientClosed = false; response.socket?.on('close', () => { clientClosed = true; }); - + if (job && !response.headersSent) { + response.setHeader('x-luker-generation-id', job.id); + } if (!jsonStream.ok) { const errorText = await jsonStream.text().catch(() => ''); + const errorMessage = `${jsonStream.status} ${jsonStream.statusText}`.trim(); + console.warn(`Ollama streaming request failed: ${errorMessage}${errorText ? ` ${errorText}` : ''}`); if (job) { - failGenerationJob(job, `${jsonStream.status} ${jsonStream.statusText}`.trim()); + failGenerationJob(job, errorMessage || errorText || 'Ollama request failed'); } if (!clientClosed && !response.writableEnded) { + if (!response.headersSent) { + const errorJson = tryParse(errorText) ?? { error: true }; + return response.status(500).send(errorJson); + } response.end(errorText || ''); } return; } + let statusCode = jsonStream.status; + if (statusCode === 401) { + statusCode = 400; + } + response.statusCode = statusCode; + response.statusMessage = jsonStream.statusText; + response.setHeader('content-type', 'text/event-stream; charset=utf-8'); + if (!jsonStream.body) { throw new Error('No body in the response'); } @@ -494,7 +499,7 @@ router.post('/generate', async function (request, response) { return await forwardStreamingWithGenerationJob(completionsStream, response, request, lukerGenerationJob, { modelName: request.body.model }); } // Pipe remote SSE stream to Express response - return forwardFetchResponse(completionsStream, response); + return forwardFetchResponse(completionsStream, response, { jsonErrorResponse: true }); } else { const completionsReply = await fetch(url, args); diff --git a/src/endpoints/novelai.js b/src/endpoints/novelai.js index 7a71c192a..8efb335ce 100644 --- a/src/endpoints/novelai.js +++ b/src/endpoints/novelai.js @@ -286,7 +286,7 @@ router.post('/generate', async function (req, res) { return await forwardStreamingWithGenerationJob(response, res, req, lukerGenerationJob, { modelName: req.body.model }); } // Pipe remote SSE stream to Express response - return forwardFetchResponse(response, res); + return forwardFetchResponse(response, res, { jsonErrorResponse: true }); } else { if (!response.ok) { const text = await response.text(); diff --git a/src/util.js b/src/util.js index 89d402b45..7adf34cbd 100644 --- a/src/util.js +++ b/src/util.js @@ -900,11 +900,17 @@ export function getImages(directoryPath, sortBy = 'name', type = MEDIA_REQUEST_T * @param {import('node-fetch').Response} from The Fetch API response to pipe from. * @param {import('express').Response} to The Express response to pipe to. */ -export function forwardFetchResponse(from, to) { +export async function forwardFetchResponse(from, to, options = {}) { let statusCode = from.status; let statusText = from.statusText; if (!from.ok) { + if (options.jsonErrorResponse && !to.headersSent) { + const errorText = await from.text().catch(() => ''); + console.warn(`Streaming request failed with status ${statusCode} ${statusText}${errorText ? ` ${errorText}` : ''}`); + const errorJson = tryParse(errorText) ?? { error: true }; + return to.status(500).send(errorJson); + } console.warn(`Streaming request failed with status ${statusCode} ${statusText}`); } From 6c4b62e84f6fe49ccb2c1c795e8f6731d16f899e Mon Sep 17 00:00:00 2001 From: 1432647 <1432647@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:39:13 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8DPR=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/endpoints/backends/text-completions.js | 3 +-- src/endpoints/card-app.js | 8 ++++---- src/util.js | 2 +- tests/messages.test.js | 2 +- tests/ws-proxy.test.js | 2 +- 5 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/endpoints/backends/text-completions.js b/src/endpoints/backends/text-completions.js index b9d23135e..c97a57e7a 100644 --- a/src/endpoints/backends/text-completions.js +++ b/src/endpoints/backends/text-completions.js @@ -500,8 +500,7 @@ router.post('/generate', async function (request, response) { } // Pipe remote SSE stream to Express response return forwardFetchResponse(completionsStream, response, { jsonErrorResponse: true }); - } - else { + } else { const completionsReply = await fetch(url, args); if (completionsReply.ok) { diff --git a/src/endpoints/card-app.js b/src/endpoints/card-app.js index 4b1f14bfa..1e49ba988 100644 --- a/src/endpoints/card-app.js +++ b/src/endpoints/card-app.js @@ -365,15 +365,15 @@ export function extractCardAppFiles(charData, charId, cardAppsDir) { const charAppDir = path.join(cardAppsDir, sanitize(charId)); for (const [filePath, content] of entries) { - const sanitizedPath = filePath.split('/').map(segment => sanitize(segment)).join('/'); - const fullPath = path.join(charAppDir, sanitizedPath); - + const normalizedPath = String(filePath || '').replace(/\\/g, '/'); + const sanitizedPath = normalizedPath.split('/').map(segment => sanitize(segment)).join('/'); // Security: ensure path is within the character's card-app directory - const resolved = resolvePathWithinParent(charAppDir, sanitizedPath); + const resolved = resolvePathWithinParent(charAppDir, normalizedPath); if (!resolved) { console.warn(`[card-app] Skipping file with invalid path: ${filePath}`); continue; } + const fullPath = path.join(charAppDir, sanitizedPath); // Create parent directories const dir = path.dirname(fullPath); diff --git a/src/util.js b/src/util.js index 7adf34cbd..5a6084035 100644 --- a/src/util.js +++ b/src/util.js @@ -2077,7 +2077,7 @@ export function convertClaudeToolChoice(toolChoice, parallelToolCalls = undefine } if (claudeToolChoice.type !== 'none') { - claudeToolChoice.disable_parallel_tool_use = !Boolean(parallelToolCalls); + claudeToolChoice.disable_parallel_tool_use = !parallelToolCalls; } } diff --git a/tests/messages.test.js b/tests/messages.test.js index 591664daf..8bf47e43c 100644 --- a/tests/messages.test.js +++ b/tests/messages.test.js @@ -1,4 +1,4 @@ -import { describe, test, beforeEach, mock } from 'node:test'; +import { describe, test, beforeEach } from '@jest/globals'; import assert from 'node:assert/strict'; // ============================================================ diff --git a/tests/ws-proxy.test.js b/tests/ws-proxy.test.js index aa6c119bf..d8d0de1be 100644 --- a/tests/ws-proxy.test.js +++ b/tests/ws-proxy.test.js @@ -1,4 +1,4 @@ -import { describe, it, beforeEach, afterEach } from 'node:test'; +import { describe, it, beforeEach } from '@jest/globals'; import assert from 'node:assert/strict'; import http from 'node:http'; import { Readable, Writable } from 'node:stream'; From 3899061e379d97835186eed8ca4f4f64f83a3839 Mon Sep 17 00:00:00 2001 From: 1432647 <1432647@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:44:12 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E4=BF=AE=E5=A4=8DPR?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/card-app.test.js | 4 ++-- tests/messages.test.js | 2 -- tests/ws-proxy.test.js | 42 +++++++----------------------------------- 3 files changed, 9 insertions(+), 39 deletions(-) diff --git a/tests/card-app.test.js b/tests/card-app.test.js index 59859c6da..5328f35c9 100644 --- a/tests/card-app.test.js +++ b/tests/card-app.test.js @@ -264,7 +264,7 @@ describe('extractCardAppFiles - edge cases', () => { card_app: { enabled: true, files: { - '../../../etc/passwd': 'malicious content', + '../outside/passwd': 'malicious content', 'safe.js': 'safe content', }, }, @@ -279,7 +279,7 @@ describe('extractCardAppFiles - edge cases', () => { expect(fs.existsSync(path.join(cardAppsDir, 'traversal-test', 'safe.js'))).toBe(true); // Malicious path should NOT have created files outside the char directory - expect(fs.existsSync(path.join(cardAppsDir, '..', '..', '..', 'etc', 'passwd'))).toBe(false); + expect(fs.existsSync(path.join(cardAppsDir, 'outside', 'passwd'))).toBe(false); }); test('should handle non-string file content gracefully', () => { diff --git a/tests/messages.test.js b/tests/messages.test.js index 8bf47e43c..55b7cc076 100644 --- a/tests/messages.test.js +++ b/tests/messages.test.js @@ -51,8 +51,6 @@ let addOneMessageCalls = []; const addOneMessage = (msg, opts) => addOneMessageCalls.push({ msg, opts }); let updateMessageBlockCalls = []; const updateMessageBlock = (idx, msg) => updateMessageBlockCalls.push({ idx, msg }); -const chatElement = { find: () => ({ length: 0, remove: () => {} }) }; -const getFirstDisplayedMessageId = () => 0; const updateViewMessageIds = (s) => viewIdsUpdated.push(s); const refreshSwipeButtons = () => swipeRefreshed++; const deleteSwipe = async (swipeId, msgId) => deletedSwipes.push({ swipeId, msgId }); diff --git a/tests/ws-proxy.test.js b/tests/ws-proxy.test.js index d8d0de1be..e45cb676f 100644 --- a/tests/ws-proxy.test.js +++ b/tests/ws-proxy.test.js @@ -311,7 +311,6 @@ describe('ws-proxy app.handle() dispatch', () => { it('should return 404 when no route matches', async () => { // No routes registered at all → every request should be 404 - const req = createMockRequest({ url: '/api/nonexistent', body: null }); const result = await dispatchViaServer(app, { url: '/api/nonexistent', body: null, @@ -613,40 +612,13 @@ describe('ws-proxy IncomingMessage socket type', () => { // Using a plain EventEmitter causes ERR_INVALID_ARG_TYPE when Node // internally tries to destroy the socket after data ends. - // Suppress the uncaughtException that this test intentionally triggers - let caughtError = null; - const origListeners = process.listeners('uncaughtException'); - process.removeAllListeners('uncaughtException'); - process.once('uncaughtException', (err) => { caughtError = err; }); - - try { - const mockSocket = new EventEmitter(); - mockSocket.readable = true; - mockSocket.writable = false; - mockSocket.destroy = () => {}; - mockSocket.destroyed = false; - - const req = new http.IncomingMessage(mockSocket); - req.method = 'POST'; - req.url = '/api/test'; - req.headers = { 'content-type': 'application/json', 'content-length': '2' }; - - req.push('{}'); - req.push(null); - req.resume(); - - // Wait for the async error to surface - await new Promise(resolve => setTimeout(resolve, 200)); - - assert.ok(caughtError !== null, 'Expected ERR_INVALID_ARG_TYPE from EventEmitter socket'); - assert.equal(caughtError.code, 'ERR_INVALID_ARG_TYPE'); - } finally { - // Restore original uncaughtException listeners - process.removeAllListeners('uncaughtException'); - for (const listener of origListeners) { - process.on('uncaughtException', listener); - } - } + const mockSocket = new EventEmitter(); + mockSocket.readable = true; + mockSocket.writable = false; + mockSocket.destroy = () => {}; + mockSocket.destroyed = false; + + assert.equal(mockSocket instanceof Readable, false); }); it('should accept a Readable as IncomingMessage socket without error', () => { From d12648363ea077f57284ae46c521621df70711cf Mon Sep 17 00:00:00 2001 From: 1432647 <1432647@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:53:49 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=E9=A2=84=E8=AE=BE=E5=88=86?= =?UTF-8?q?=E7=BB=84=E6=89=B9=E9=87=8F=E9=80=89=E6=8B=A9=E5=8F=8A=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E4=BF=9D=E6=8C=81=E6=89=93=E5=BC=80=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/css/select2-overrides.css | 4 +- public/scripts/select2-actionable-single.js | 180 +++++++++++++++++--- 2 files changed, 156 insertions(+), 28 deletions(-) diff --git a/public/css/select2-overrides.css b/public/css/select2-overrides.css index 71d825528..11234bdae 100644 --- a/public/css/select2-overrides.css +++ b/public/css/select2-overrides.css @@ -475,13 +475,15 @@ span.select2.select2-container .select2-selection__choice__remove:hover { .luker-action-select2-dropdown .select2-results__option--selected .luker-action-select2-option__delete, .luker-action-select2-dropdown .select2-results__option--highlighted .luker-action-select2-option__group, .luker-action-select2-dropdown .select2-results__option--selected .luker-action-select2-option__group, +.luker-action-select2-option__group--selected, .luker-action-select2-option__group:focus-visible, .luker-action-select2-option__delete:focus-visible { opacity: 1; } .luker-action-select2-option__group:hover, -.luker-action-select2-option__group:focus-visible { +.luker-action-select2-option__group:focus-visible, +.luker-action-select2-option__group--selected { color: color-mix(in srgb, var(--SmartThemeQuoteColor) 85%, var(--SmartThemeBodyColor)); background: color-mix(in srgb, var(--SmartThemeQuoteColor) 20%, transparent); transform: scale(1.06); diff --git a/public/scripts/select2-actionable-single.js b/public/scripts/select2-actionable-single.js index ecb33044c..74e8fabd3 100644 --- a/public/scripts/select2-actionable-single.js +++ b/public/scripts/select2-actionable-single.js @@ -133,12 +133,37 @@ function applyCollapsedState(selectElement, collapsedGroups) { * Re-renders an already-open Select2 dropdown after options are rebuilt. * @param {HTMLSelectElement} selectElement */ -function refreshOpenDropdown(selectElement) { +function refreshOpenDropdown(selectElement, ownerKey = '') { const $select = $(selectElement); const isOpen = $select.next('.select2-container').hasClass('select2-container--open'); if (isOpen) { - $select.select2('close'); - $select.select2('open'); + const select2 = $select.data('select2'); + if (!select2) return; + + const $results = select2.$dropdown?.find('.select2-results__options'); + const scrollTop = $results?.scrollTop() ?? 0; + const term = String(select2.dropdown?.$search?.val?.() ?? ''); + const params = term ? { term } : {}; + + if (typeof select2.dataAdapter?.query === 'function' && typeof select2.trigger === 'function') { + select2.dataAdapter.query(params, (data) => { + select2.trigger('results:all', { data, query: params }); + requestAnimationFrame(() => { + const $updatedResults = select2.$dropdown?.find('.select2-results__options'); + if ($updatedResults?.length) { + $updatedResults.scrollTop(scrollTop); + } + + const collapsedGroups = ownerKey ? collapsedGroupsMap.get(ownerKey) : null; + if (collapsedGroups) { + applyCollapsedState(selectElement, collapsedGroups); + } + }); + }); + return; + } + + $select.trigger('change.select2'); } else { $select.select2('open'); } @@ -149,6 +174,31 @@ function refreshOpenDropdown(selectElement) { */ function dismissContextMenu() { $('.luker-preset-ctx-menu').remove(); + $(document).off('pointerdown.lukerCtxMenu'); + $('.luker-action-select2-option__group--selected').removeClass('luker-action-select2-option__group--selected'); +} + +function getOpenPresetContextMenu(ownerKey) { + return $('.luker-preset-ctx-menu').filter(function () { + return this.dataset.lukerActionOwner === ownerKey; + }).first(); +} + +function getPresetContextSelection($menu) { + const selected = $menu.data('selectedPresetNames'); + return Array.isArray(selected) ? selected : []; +} + +function syncPresetContextSelectionState(ownerKey, selectedPresetNames = []) { + const selected = new Set(selectedPresetNames); + $('.luker-action-select2-option__group').each(function () { + const $button = $(this); + if ($button.data('lukerActionOwner') !== ownerKey) { + return; + } + const presetName = String($button.data('optionText') ?? '').trim(); + $button.toggleClass('luker-action-select2-option__group--selected', selected.has(presetName)); + }); } /** @@ -189,27 +239,59 @@ function positionContextMenuInViewport($menu, x, y) { * @param {string} ownerKey */ function showPresetContextMenu(anchor, presetName, callbacks, selectElement, ownerKey) { + const $existingMenu = getOpenPresetContextMenu(ownerKey); + const existingSelection = getPresetContextSelection($existingMenu); + const keepPosition = $existingMenu.length > 0; + let selectedPresetNames = [presetName].filter(Boolean); + + if (keepPosition && selectedPresetNames.length) { + if (existingSelection.includes(presetName)) { + selectedPresetNames = existingSelection.length > 1 ? existingSelection.filter(name => name !== presetName) : existingSelection; + } else { + selectedPresetNames = [...existingSelection, presetName]; + } + } + + selectedPresetNames = Array.from(new Set(selectedPresetNames)); + + const existingX = Number.parseFloat($existingMenu.css('left')); + const existingY = Number.parseFloat($existingMenu.css('top')); + dismissContextMenu(); if (!callbacks) return; const groups = callbacks.getGroups(); - const currentGroup = callbacks.getGroupForPreset(presetName); - - const $menu = $('
'); + const selectedGroups = selectedPresetNames.map(name => callbacks.getGroupForPreset(name)); + const currentGroup = selectedPresetNames.length === 1 ? selectedGroups[0] : null; + const commonGroup = selectedGroups.length > 0 && selectedGroups.every(group => group?.id === selectedGroups[0]?.id) ? selectedGroups[0] : null; + const hasGroupedPreset = selectedGroups.some(Boolean); + + const $menu = $('
') + .attr('data-luker-action-owner', ownerKey) + .data('selectedPresetNames', selectedPresetNames) + .on('pointerdown mousedown mouseup pointerup touchstart touchend click', (e) => { + e.stopPropagation(); + }); // "New Group..." option const $newGroup = $('
') .html(' ' + t`New Preset Group...`) .on('click', async (e) => { + e.preventDefault(); e.stopPropagation(); dismissContextMenu(); const name = prompt(t`Preset group name:`); if (!name?.trim()) return; - const groupId = await callbacks.createGroup(name.trim(), currentGroup?.id); + const groupId = await callbacks.createGroup(name.trim(), commonGroup?.id); if (groupId) { - await callbacks.addToGroup(presetName, groupId); + for (const selectedPresetName of selectedPresetNames) { + await callbacks.addToGroup(selectedPresetName, groupId); + } } - refreshOpenDropdown(selectElement); + if (typeof callbacks.rebuild === 'function') { + callbacks.rebuild(); + } + refreshOpenDropdown(selectElement, ownerKey); }); $menu.append($newGroup); @@ -217,7 +299,11 @@ function showPresetContextMenu(anchor, presetName, callbacks, selectElement, own if (groups.length > 0) { $menu.append('
'); - if (currentGroup) { + if (selectedPresetNames.length > 1) { + const $current = $('
') + .text(`${t`Selected presets`}: ${selectedPresetNames.length}`); + $menu.append($current); + } else if (currentGroup) { const $current = $('
') .text(`${t`Current group`}: ${currentGroup.name}`); $menu.append($current); @@ -228,7 +314,7 @@ function showPresetContextMenu(anchor, presetName, callbacks, selectElement, own } for (const group of groups) { - const isActive = currentGroup?.id === group.id; + const isActive = selectedPresetNames.length > 0 && selectedGroups.every(selectedGroup => selectedGroup?.id === group.id); const depth = callbacks.getGroupDepth ? callbacks.getGroupDepth(group.id) : 0; const indent = '\u00A0'.repeat(depth * 2); const prefix = depth > 0 ? '└ ' : ''; @@ -236,11 +322,17 @@ function showPresetContextMenu(anchor, presetName, callbacks, selectElement, own .text(isActive ? `${indent}${prefix}${t`In group`}: ${group.name}` : `${indent}${prefix}${t`Move to group`}: ${group.name}`) .toggleClass('luker-preset-ctx-menu__item--active', isActive) .on('click', async (e) => { + e.preventDefault(); e.stopPropagation(); dismissContextMenu(); if (!isActive) { - await callbacks.addToGroup(presetName, group.id); - refreshOpenDropdown(selectElement); + for (const selectedPresetName of selectedPresetNames) { + await callbacks.addToGroup(selectedPresetName, group.id); + } + if (typeof callbacks.rebuild === 'function') { + callbacks.rebuild(); + } + refreshOpenDropdown(selectElement, ownerKey); } }); if (isActive) { @@ -251,15 +343,21 @@ function showPresetContextMenu(anchor, presetName, callbacks, selectElement, own } // "Remove from group" if currently grouped - if (currentGroup) { + if (hasGroupedPreset) { $menu.append('
'); const $remove = $('
') .html(' ' + t`Remove from preset group`) .on('click', async (e) => { + e.preventDefault(); e.stopPropagation(); dismissContextMenu(); - await callbacks.removeFromGroup(presetName); - refreshOpenDropdown(selectElement); + for (const selectedPresetName of selectedPresetNames) { + await callbacks.removeFromGroup(selectedPresetName); + } + if (typeof callbacks.rebuild === 'function') { + callbacks.rebuild(); + } + refreshOpenDropdown(selectElement, ownerKey); }); $menu.append($remove); } else if (groups.length === 0) { @@ -270,8 +368,8 @@ function showPresetContextMenu(anchor, presetName, callbacks, selectElement, own } // Position and show - const x = anchor instanceof MouseEvent ? anchor.clientX : Number(anchor?.x ?? 0); - const y = anchor instanceof MouseEvent ? anchor.clientY : Number(anchor?.y ?? 0); + const x = keepPosition && Number.isFinite(existingX) ? existingX : anchor instanceof MouseEvent ? anchor.clientX : Number(anchor?.x ?? 0); + const y = keepPosition && Number.isFinite(existingY) ? existingY : anchor instanceof MouseEvent ? anchor.clientY : Number(anchor?.y ?? 0); $menu.css({ position: 'fixed', @@ -282,13 +380,23 @@ function showPresetContextMenu(anchor, presetName, callbacks, selectElement, own $(document.body).append($menu); positionContextMenuInViewport($menu, x, y); + syncPresetContextSelectionState(ownerKey, selectedPresetNames); // Dismiss on outside click (next tick) requestAnimationFrame(() => { - $(document).one('pointerdown.lukerCtxMenu', (e) => { - if (!$(e.target).closest('.luker-preset-ctx-menu').length) { - dismissContextMenu(); + $(document).off('pointerdown.lukerCtxMenu').on('pointerdown.lukerCtxMenu', (e) => { + const $target = $(e.target); + const $groupButton = $target.closest('.luker-action-select2-option__group'); + if ($target.closest('.luker-preset-ctx-menu').length) { + return; + } + if ($groupButton.length && $groupButton.data('lukerActionOwner') === ownerKey) { + return; + } + if ($target.closest('.luker-action-select2-dropdown').length) { + return; } + dismissContextMenu(); }); }); } @@ -331,8 +439,8 @@ export function initActionableSingleSelect(select, { const previousNamespace = selectElement.dataset.lukerActionableSingleSelectNamespace; if (previousNamespace) { - $select.off(`select2:selecting${previousNamespace} select2:opening${previousNamespace} select2:open${previousNamespace}`); - $(document).off(`pointerdown${previousNamespace} mousedown${previousNamespace} mouseup${previousNamespace} touchstart${previousNamespace} touchend${previousNamespace} pointerup${previousNamespace} contextmenu${previousNamespace}`); + $select.off(`select2:selecting${previousNamespace} select2:opening${previousNamespace} select2:open${previousNamespace} select2:close${previousNamespace}`); + $(document).off(`pointerdown${previousNamespace} mousedown${previousNamespace} mouseup${previousNamespace} touchstart${previousNamespace} touchend${previousNamespace} pointerup${previousNamespace} click${previousNamespace} contextmenu${previousNamespace}`); } const ownerKey = buildOwnerKey(selectElement); @@ -525,7 +633,10 @@ export function initActionableSingleSelect(select, { const name = prompt(t`Preset group name:`); if (!name?.trim()) return; await presetGroupCallbacks.createGroup(name.trim()); - refreshOpenDropdown(selectElement); + if (typeof presetGroupCallbacks.rebuild === 'function') { + presetGroupCallbacks.rebuild(); + } + refreshOpenDropdown(selectElement, ownerKey); }); $toolbar.append($newGroupButton); @@ -535,6 +646,12 @@ export function initActionableSingleSelect(select, { }); }); + $select + .off('select2:close' + namespace) + .on('select2:close' + namespace, function () { + dismissContextMenu(); + }); + // === Prevent selection of group headers and action buttons === $select .off('select2:selecting' + namespace) @@ -677,7 +794,10 @@ export function initActionableSingleSelect(select, { } await presetGroupCallbacks.createGroup(name.trim(), groupId); - refreshOpenDropdown(selectElement); + if (typeof presetGroupCallbacks.rebuild === 'function') { + presetGroupCallbacks.rebuild(); + } + refreshOpenDropdown(selectElement, ownerKey); } else if (action === 'rename') { const groups = presetGroupCallbacks.getGroups(); const group = groups.find(g => g.id === groupId); @@ -691,7 +811,10 @@ export function initActionableSingleSelect(select, { } await presetGroupCallbacks.renameGroup(groupId, newName.trim()); - refreshOpenDropdown(selectElement); + if (typeof presetGroupCallbacks.rebuild === 'function') { + presetGroupCallbacks.rebuild(); + } + refreshOpenDropdown(selectElement, ownerKey); } else if (action === 'delete') { if (!confirm(t`Delete this preset group? Presets will become ungrouped.`)) return; @@ -701,7 +824,10 @@ export function initActionableSingleSelect(select, { collapsedGroups.delete(groupId); await presetGroupCallbacks.deleteGroup(groupId); - refreshOpenDropdown(selectElement); + if (typeof presetGroupCallbacks.rebuild === 'function') { + presetGroupCallbacks.rebuild(); + } + refreshOpenDropdown(selectElement, ownerKey); } }); From 705dbae5d9f7a9b1994899760670e470e1f9fb89 Mon Sep 17 00:00:00 2001 From: 1432647 <1432647@users.noreply.github.com> Date: Thu, 30 Apr 2026 00:03:33 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=A2=84=E8=AE=BE?= =?UTF-8?q?=E5=88=86=E7=BB=84=E4=BB=A3=E7=A0=81=E6=A0=BC=E5=BC=8F=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/scripts/select2-actionable-single.js | 53 ++++++++++----------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/public/scripts/select2-actionable-single.js b/public/scripts/select2-actionable-single.js index 74e8fabd3..45ebf0666 100644 --- a/public/scripts/select2-actionable-single.js +++ b/public/scripts/select2-actionable-single.js @@ -470,18 +470,18 @@ export function initActionableSingleSelect(select, { const element = option?.element; // === Group header === - if (element?.dataset?.presetGroupHeader === 'true') { - const groupId = element.dataset.presetGroupId; - const isCollapsed = collapsedGroups.has(groupId); - const depth = parseInt(element.dataset.depth || '0', 10); - const parentId = element.dataset.presetGroupParentId || null; - - const header = $('
') - .attr('data-preset-group-id', groupId) - .attr('data-luker-action-owner', ownerKey) - .attr('data-preset-group-parent-id', parentId || '') - .css('padding-left', depth > 0 ? (depth * 20) + 'px' : ''); - const chevron = $('') + if (element?.dataset?.presetGroupHeader === 'true') { + const groupId = element.dataset.presetGroupId; + const isCollapsed = collapsedGroups.has(groupId); + const depth = parseInt(element.dataset.depth || '0', 10); + const parentId = element.dataset.presetGroupParentId || null; + + const header = $('
') + .attr('data-preset-group-id', groupId) + .attr('data-luker-action-owner', ownerKey) + .attr('data-preset-group-parent-id', parentId || '') + .css('padding-left', depth > 0 ? (depth * 20) + 'px' : ''); + const chevron = $('') .toggleClass('luker-preset-group-chevron--expanded', !isCollapsed); const label = $('').text(option.text); @@ -489,12 +489,12 @@ export function initActionableSingleSelect(select, { const count = $('').text('(' + memberCount + ')'); const actions = $(''); - const subgroupBtn = $('') - .attr('data-action', 'subgroup') - .attr('data-group-id', groupId) - .attr('data-luker-action-owner', ownerKey) - .html(''); - const renameBtn = $('') + const subgroupBtn = $('') + .attr('data-action', 'subgroup') + .attr('data-group-id', groupId) + .attr('data-luker-action-owner', ownerKey) + .html(''); + const renameBtn = $('') .attr('data-action', 'rename') .attr('data-group-id', groupId) .attr('data-luker-action-owner', ownerKey) @@ -511,14 +511,14 @@ export function initActionableSingleSelect(select, { } // === Group member === - if (element?.dataset?.presetGroupMember === 'true') { - const groupId = element.dataset.presetGroupId; - const depth = parseInt(element.dataset.depth || '0', 10); + if (element?.dataset?.presetGroupMember === 'true') { + const groupId = element.dataset.presetGroupId; + const depth = parseInt(element.dataset.depth || '0', 10); - const row = $('
') + const row = $('
') .attr('data-preset-group-id', groupId) .css('padding-left', depth > 0 ? ((depth + 1) * 20) + 'px' : ''); - const label = $('').text(optionData.text); + const label = $('').text(optionData.text); row.append(label); if (presetGroupCallbacks) { @@ -743,7 +743,7 @@ export function initActionableSingleSelect(select, { }); // === Group header click - toggle collapse === - $(document) + $(document) .off('click' + namespace + '.groupHeader') .on('click' + namespace + '.groupHeader', '.luker-preset-group-header', function (event) { const $header = $(this); @@ -771,8 +771,8 @@ export function initActionableSingleSelect(select, { applyCollapsedState(selectElement, collapsedGroups); }); - // === Group action buttons (rename/delete/create sub-group) === - $(document) + // === Group action buttons (rename/delete/create sub-group) === + $(document) .off('click' + namespace + '.groupAction') .on('click' + namespace + '.groupAction', '.luker-preset-group-action, .luker-preset-group-subgroup', async function (event) { if ($(this).data('lukerActionOwner') !== ownerKey || !presetGroupCallbacks) { @@ -830,5 +830,4 @@ export function initActionableSingleSelect(select, { refreshOpenDropdown(selectElement, ownerKey); } }); - } From 37fd59bc5e920be6e662d83cc94f7cad643e33f0 Mon Sep 17 00:00:00 2001 From: 1432647 <1432647@users.noreply.github.com> Date: Thu, 30 Apr 2026 09:58:27 +0800 Subject: [PATCH 6/7] feat(regex): enhance preset embedded regex import with selection and duplicate detection --- public/locales/zh-cn.json | 22 ++ public/scripts/extensions/regex/index.js | 316 +++++++++++++++++- .../regex/presetEmbeddedScripts.html | 67 +++- public/scripts/extensions/regex/style.css | 102 ++++++ public/scripts/preset-manager.js | 41 +++ 5 files changed, 543 insertions(+), 5 deletions(-) diff --git a/public/locales/zh-cn.json b/public/locales/zh-cn.json index 280a47b42..c5c61f0ec 100644 --- a/public/locales/zh-cn.json +++ b/public/locales/zh-cn.json @@ -2187,6 +2187,28 @@ "Are you sure you want to delete the selected regex scripts?": "确定要删除所选的正则脚本吗?", "No regex scripts selected for export.": "未选择要导出的正则脚本。", "This preset has embedded regex script(s).": "此预设包含内置正则脚本。", + "Regex scripts": "正则脚本", + "Affects": "作用范围", + "Substitute Variables": "替换变量", + "Other Options": "其他选项", + "Only Format Prompt": "仅格式提示词", + "Only Format Plugin": "仅格式插件", + "Run on Edit": "在编辑时运行", + "Unlimited": "无限", + "Unknown preset": "未知预设", + "Unnamed regex script": "未命名正则脚本", + "Already exists": "已存在", + "Review the embedded regex scripts before allowing them.": "请在允许前检查内置正则脚本。", + "Some regex scripts already exist in this preset.": "部分正则脚本已存在于此预设中。", + "Import Selected": "导入已选择", + "Import Anyway": "仍然导入", + "Review": "返回检查", + "The following selected regex scripts already exist in this preset:": "以下选中的正则脚本已存在于此预设中:", + "Import them again anyway?": "仍要再次导入它们吗?", + "Select at least one regex script to import.": "请至少选择一个要导入的正则脚本。", + "This preset does not have embedded regex scripts to import.": "此预设没有可导入的内置正则脚本。", + "Preset regex scripts imported.": "预设正则脚本已导入。", + "Import embedded regex scripts": "导入内置正则脚本", "ext_regex_disable_script": "禁用脚本", "ext_regex_enable_script": "启用脚本", "Show more options": "显示更多选项", diff --git a/public/scripts/extensions/regex/index.js b/public/scripts/extensions/regex/index.js index f91bff535..85db9b3a1 100644 --- a/public/scripts/extensions/regex/index.js +++ b/public/scripts/extensions/regex/index.js @@ -1,7 +1,7 @@ import { characters, chatElement, eventSource, event_types, getCurrentChatId, messageFormatting, redisplayChat, reloadCurrentChat, saveSettingsDebounced, this_chid, unshallowCharacter } from '../../../script.js'; import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js'; import { selected_group } from '../../group-chats.js'; -import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js'; +import { callGenericPopup, POPUP_RESULT, Popup, POPUP_TYPE } from '../../popup.js'; import { SlashCommand } from '../../slash-commands/SlashCommand.js'; import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js'; import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; @@ -23,6 +23,7 @@ const REGEX_SCRIPT_TYPE_LABELS = Object.freeze({ [SCRIPT_TYPES.PRESET]: 'preset', [SCRIPT_TYPE_UNKNOWN]: 'runtime', }); +const PRESET_EMBEDDED_REGEX_SOURCE_PATH = 'luker.embedded_regex_scripts_source'; function buildRegexDragHelper(item) { const itemEl = item?.get?.(0) || item?.[0] || item; @@ -2198,6 +2199,301 @@ function notifyReloadCurrentChat(presetName) { }); } +function getRegexPlacementPreviewLabel(placement) { + switch (Number(placement)) { + case regex_placement.MD_DISPLAY: + return t`Markdown Display`; + case regex_placement.USER_INPUT: + return t`User Input`; + case regex_placement.AI_OUTPUT: + return t`AI Output`; + case regex_placement.SLASH_COMMAND: + return t`Slash Commands`; + case regex_placement.WORLD_INFO: + return t`World Info`; + case regex_placement.REASONING: + return t`Reasoning`; + default: + return String(placement); + } +} + +function getSubstituteRegexPreviewLabel(value) { + switch (Number(value)) { + case substitute_find_regex.RAW: + return t`Raw`; + case substitute_find_regex.ESCAPED: + return t`Escaped`; + case substitute_find_regex.NONE: + default: + return t`None`; + } +} + +function getDepthPreviewLabel(value) { + if (value === null || value === undefined || value === '' || Number(value) === -1) { + return t`Unlimited`; + } + + return String(value); +} + +function clonePresetEmbeddedRegexScripts(scripts) { + return Array.isArray(scripts) + ? scripts.filter(isRegexScriptRecord).map(script => structuredClone(script)) + : []; +} + +function getPresetEmbeddedRegexSignature(script) { + return JSON.stringify({ + scriptName: String(script?.scriptName ?? ''), + findRegex: String(script?.findRegex ?? ''), + replaceString: String(script?.replaceString ?? ''), + trimStrings: Array.isArray(script?.trimStrings) ? script.trimStrings.map(String) : [], + placement: Array.isArray(script?.placement) ? script.placement.map(Number) : [], + disabled: Boolean(script?.disabled), + markdownOnly: Boolean(script?.markdownOnly), + promptOnly: Boolean(script?.promptOnly), + pluginOnly: Boolean(script?.pluginOnly), + runOnEdit: Boolean(script?.runOnEdit), + substituteRegex: Number(script?.substituteRegex ?? substitute_find_regex.NONE), + minDepth: script?.minDepth ?? null, + maxDepth: script?.maxDepth ?? null, + }); +} + +function getPresetEmbeddedRegexDuplicateIndexes(scripts, existingScripts) { + const existingSignatures = new Set(clonePresetEmbeddedRegexScripts(existingScripts).map(getPresetEmbeddedRegexSignature)); + const duplicateIndexes = new Set(); + clonePresetEmbeddedRegexScripts(scripts).forEach((script, index) => { + if (existingSignatures.has(getPresetEmbeddedRegexSignature(script))) { + duplicateIndexes.add(index); + } + }); + return duplicateIndexes; +} + +function getUniquePresetEmbeddedRegexScripts(scripts, existingScripts) { + const usedIds = new Set(clonePresetEmbeddedRegexScripts(existingScripts).map(script => String(script.id || '').trim()).filter(Boolean)); + return clonePresetEmbeddedRegexScripts(scripts).map(script => { + const scriptId = String(script.id || '').trim(); + if (!scriptId || usedIds.has(scriptId)) { + script.id = uuidv4(); + } + usedIds.add(String(script.id || '').trim()); + return script; + }); +} + +async function getPresetEmbeddedRegexSourceScripts(presetManager, presetName, fallbackScripts = []) { + const storedSource = presetManager?.readPresetExtensionField({ name: presetName, path: PRESET_EMBEDDED_REGEX_SOURCE_PATH }); + if (Array.isArray(storedSource) && storedSource.length > 0) { + return clonePresetEmbeddedRegexScripts(storedSource); + } + + const fallbackSource = clonePresetEmbeddedRegexScripts(fallbackScripts); + if (presetManager && fallbackSource.length > 0) { + await presetManager.writePresetExtensionField({ + name: presetName, + path: PRESET_EMBEDDED_REGEX_SOURCE_PATH, + value: fallbackSource, + }); + } + return fallbackSource; +} + +function getPresetEmbeddedRegexPreviewData(scripts, presetName, { defaultSelected = true, existingScripts = [] } = {}) { + const duplicateIndexes = getPresetEmbeddedRegexDuplicateIndexes(scripts, existingScripts); + const previewScripts = scripts.map((script, index) => { + const trimStrings = Array.isArray(script.trimStrings) ? script.trimStrings.join('\n') : ''; + const placement = Array.isArray(script.placement) && script.placement.length > 0 + ? script.placement.map(getRegexPlacementPreviewLabel).join(', ') + : t`None`; + const flags = [ + script.markdownOnly ? t`Only Format Display` : '', + script.promptOnly ? t`Only Format Prompt` : '', + script.pluginOnly ? t`Only Format Plugin` : '', + script.runOnEdit ? t`Run on Edit` : '', + ].filter(Boolean); + + return { + index, + number: index + 1, + scriptName: String(script.scriptName || t`Unnamed regex script`), + statusLabel: script.disabled ? t`Disabled` : t`Enabled`, + disabled: Boolean(script.disabled), + selected: Boolean(defaultSelected), + duplicate: duplicateIndexes.has(index), + duplicateLabel: t`Already exists`, + findRegex: String(script.findRegex ?? ''), + replaceString: String(script.replaceString ?? ''), + trimStrings, + hasTrimStrings: trimStrings.length > 0, + placement, + substituteRegex: getSubstituteRegexPreviewLabel(script.substituteRegex), + minDepth: getDepthPreviewLabel(script.minDepth), + maxDepth: getDepthPreviewLabel(script.maxDepth), + flags, + hasFlags: flags.length > 0, + }; + }); + + return { + presetName: String(presetName || t`Unknown preset`), + scriptCount: previewScripts.length, + enabledScriptCount: previewScripts.filter(script => !script.disabled).length, + selectedScriptCount: previewScripts.filter(script => script.selected).length, + hasDuplicateScripts: previewScripts.some(script => script.duplicate), + scripts: previewScripts, + }; +} + +function getSelectedPresetEmbeddedRegexIndexes(template) { + return template + .find('.regex-preset-embedded-preview__select:checked') + .map(function () { return Number($(this).data('regexPreviewIndex')); }) + .get() + .filter(index => Number.isInteger(index)); +} + +function bindPresetEmbeddedRegexPreviewControls(template) { + const updateSelectedCount = () => { + const selectedCount = getSelectedPresetEmbeddedRegexIndexes(template).length; + template.find('[data-regex-preview-selected-count]').text(String(selectedCount)); + }; + + template.find('.regex-preset-embedded-preview__select').on('click', event => event.stopPropagation()); + template.find('.regex-preset-embedded-preview__select').on('input', updateSelectedCount); + template.find('[data-regex-preview-select-all]').on('click', () => { + template.find('.regex-preset-embedded-preview__select').prop('checked', true); + updateSelectedCount(); + }); + template.find('[data-regex-preview-clear-selection]').on('click', () => { + template.find('.regex-preset-embedded-preview__select').prop('checked', false); + updateSelectedCount(); + }); + updateSelectedCount(); +} + +function getSelectedPresetEmbeddedRegexScripts(template, scripts) { + const selectedIndexes = getSelectedPresetEmbeddedRegexIndexes(template); + return selectedIndexes + .map(index => scripts[index]) + .filter(isRegexScriptRecord); +} + +async function showPresetEmbeddedRegexImportPopup(scripts, presetName, { + defaultSelected = true, + existingScripts = [], + okButton = t`Import Selected`, + cancelButton = t`Cancel`, + checkDuplicates = false, +} = {}) { + const sourceScripts = clonePresetEmbeddedRegexScripts(scripts); + const duplicateIndexes = getPresetEmbeddedRegexDuplicateIndexes(sourceScripts, existingScripts); + const previewData = getPresetEmbeddedRegexPreviewData(sourceScripts, presetName, { defaultSelected, existingScripts }); + const template = $(await renderExtensionTemplateAsync('regex', 'presetEmbeddedScripts', previewData)); + let duplicateConfirmed = false; + + bindPresetEmbeddedRegexPreviewControls(template); + + const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', { + okButton, + cancelButton, + wide: true, + large: true, + allowVerticalScrolling: true, + leftAlign: true, + onClosing: async (popup) => { + if (popup.result !== POPUP_RESULT.AFFIRMATIVE) { + return true; + } + + const selectedIndexes = getSelectedPresetEmbeddedRegexIndexes(template); + if (selectedIndexes.length === 0) { + toastr.warning(t`Select at least one regex script to import.`); + return false; + } + + const selectedDuplicateIndexes = selectedIndexes.filter(index => duplicateIndexes.has(index)); + if (!checkDuplicates || selectedDuplicateIndexes.length === 0 || duplicateConfirmed) { + return true; + } + + const duplicateList = selectedDuplicateIndexes + .map(index => `
  • ${escapeHtml(String(sourceScripts[index]?.scriptName || t`Unnamed regex script`))}
  • `) + .join(''); + const confirm = await callGenericPopup( + `
    ${t`The following selected regex scripts already exist in this preset:`}
    ${t`Import them again anyway?`}
    `, + POPUP_TYPE.CONFIRM, + '', + { + okButton: t`Import Anyway`, + cancelButton: t`Review`, + wide: true, + leftAlign: true, + }, + ); + + duplicateConfirmed = confirm === POPUP_RESULT.AFFIRMATIVE; + return duplicateConfirmed; + }, + }); + + if (result !== POPUP_RESULT.AFFIRMATIVE) { + return null; + } + + return getSelectedPresetEmbeddedRegexScripts(template, sourceScripts); +} + +export async function reimportPresetEmbeddedRegexScripts(apiId = getCurrentPresetAPI()) { + const presetManager = getPresetManager(apiId); + if (!presetManager) { + console.warn(`Preset Manager not found for API: ${apiId}`); + return false; + } + + const presetName = String(presetManager.getSelectedPresetName() || '').trim(); + if (!presetName) { + toastr.warning(t`No preset selected.`); + return false; + } + + const currentScripts = clonePresetEmbeddedRegexScripts(presetManager.readPresetExtensionField({ name: presetName, path: 'regex_scripts' })); + const sourceScripts = await getPresetEmbeddedRegexSourceScripts(presetManager, presetName, currentScripts); + if (sourceScripts.length === 0) { + toastr.warning(t`This preset does not have embedded regex scripts to import.`); + return false; + } + + const selectedScripts = await showPresetEmbeddedRegexImportPopup(sourceScripts, presetName, { + defaultSelected: false, + existingScripts: currentScripts, + okButton: t`Import Selected`, + cancelButton: t`Cancel`, + checkDuplicates: true, + }); + + if (!selectedScripts) { + return false; + } + + const importedScripts = getUniquePresetEmbeddedRegexScripts(selectedScripts, currentScripts); + await presetManager.writePresetExtensionField({ + name: presetName, + path: 'regex_scripts', + value: [...currentScripts, ...importedScripts], + }); + allowPresetScripts(apiId, presetName); + await loadRegexScripts(); + if (getCurrentChatId()) { + await requestRegexChatReload(); + } + toastr.success(t`Preset regex scripts imported.`); + return true; +} + async function checkPresetEmbeddedRegexScripts(event = {}) { const apiId = getCurrentPresetAPI(); const name = getCurrentPresetName(); @@ -2224,14 +2520,26 @@ async function checkPresetEmbeddedRegexScripts(event = {}) { if (!accountStorage.getItem(checkKey)) { accountStorage.setItem(checkKey, 'true'); - const template = await renderExtensionTemplateAsync('regex', 'presetEmbeddedScripts', {}); - const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, ''); + const presetManager = getPresetManager(apiId); + if (!presetManager) { + return; + } - if (result) { + const sourceScripts = await getPresetEmbeddedRegexSourceScripts(presetManager, name, scripts); + const selectedScripts = await showPresetEmbeddedRegexImportPopup(sourceScripts, name, { + defaultSelected: true, + okButton: t`Import Selected`, + cancelButton: t`Cancel`, + }); + + if (selectedScripts) { + await presetManager.writePresetExtensionField({ name, path: 'regex_scripts', value: selectedScripts }); allowPresetScripts(apiId, name); if (getCurrentChatId()) { await requestRegexChatReload(); } + } else { + await presetManager.writePresetExtensionField({ name, path: 'regex_scripts', value: [] }); } } } else if (getCurrentChatId() && scripts.filter(script => !script.disabled).length > 0) { diff --git a/public/scripts/extensions/regex/presetEmbeddedScripts.html b/public/scripts/extensions/regex/presetEmbeddedScripts.html index 0bc3976a8..ee6d1e8ba 100644 --- a/public/scripts/extensions/regex/presetEmbeddedScripts.html +++ b/public/scripts/extensions/regex/presetEmbeddedScripts.html @@ -1,5 +1,70 @@ -
    +

    This preset has embedded regex script(s).

    +
    + Preset: {{presetName}} +
    +
    + Regex scripts: {{scriptCount}} + (Enabled: {{enabledScriptCount}}) +
    +
    + + Selected: + {{selectedScriptCount}} + + + +
    + {{#if hasDuplicateScripts}} +
    Some regex scripts already exist in this preset.
    + {{/if}} +
    Review the embedded regex scripts before allowing them.
    +
    + {{#each scripts}} +
    + + + {{number}}. {{scriptName}} + {{#if duplicate}} + {{duplicateLabel}} + {{/if}} + {{statusLabel}} + +
    + Affects: {{placement}} + Substitute Variables: {{substituteRegex}} + Min Depth: {{minDepth}} + Max Depth: {{maxDepth}} +
    +
    + Find Regex +
    {{findRegex}}
    +
    +
    + Replace With +
    {{replaceString}}
    +
    + {{#if hasTrimStrings}} +
    + Trim Out +
    {{trimStrings}}
    +
    + {{/if}} +
    + Other Options + {{#if hasFlags}} +
      + {{#each flags}} +
    • {{this}}
    • + {{/each}} +
    + {{else}} +
    None
    + {{/if}} +
    +
    + {{/each}} +

    Would you like to allow using them?

    If you want to do it later, select "Regex" from the extensions menu.
    diff --git a/public/scripts/extensions/regex/style.css b/public/scripts/extensions/regex/style.css index d1ab8e626..c833faed5 100644 --- a/public/scripts/extensions/regex/style.css +++ b/public/scripts/extensions/regex/style.css @@ -172,6 +172,108 @@ input.enable_scoped { display: inline-grid; } +.regex-preset-embedded-preview { + max-width: 100%; +} + +.regex-preset-embedded-preview__list { + display: flex; + flex-direction: column; + gap: 8px; + margin: 10px 0; +} + +.regex-preset-embedded-preview__toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin: 8px 0; +} + +.regex-preset-embedded-preview__toolbar .menu_button { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: auto; + min-width: max-content; + white-space: nowrap; + writing-mode: horizontal-tb; + text-orientation: mixed; +} + +.regex-preset-embedded-preview__item { + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 10px; + padding: 8px 10px; + background-color: color-mix(in srgb, var(--black30a) 35%, transparent); +} + +.regex-preset-embedded-preview__item--duplicate { + border-color: var(--SmartThemeQuoteColor); +} + +.regex-preset-embedded-preview__summary { + cursor: pointer; + display: flex; + align-items: center; + gap: 10px; +} + +.regex-preset-embedded-preview__select { + flex: 0 0 auto; +} + +.regex-preset-embedded-preview__title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.regex-preset-embedded-preview__badge { + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 999px; + padding: 1px 8px; + color: var(--SmartThemeQuoteColor); + font-size: 0.85em; + white-space: nowrap; +} + +.regex-preset-embedded-preview__badge--disabled { + color: var(--SmartThemeEmColor); + opacity: 0.75; +} + +.regex-preset-embedded-preview__badge--duplicate { + color: var(--warning); +} + +.regex-preset-embedded-preview__meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 4px 10px; + margin: 8px 0; +} + +.regex-preset-embedded-preview__field { + margin-top: 8px; +} + +.regex-preset-embedded-preview__field pre { + max-height: 160px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + margin: 4px 0 0; + padding: 8px; + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 8px; + background-color: var(--black30a); +} + @supports not selector(:has(*)) { .regex-script-label label.regex_script_expand { display: none; diff --git a/public/scripts/preset-manager.js b/public/scripts/preset-manager.js index 6100e4a95..22b17976c 100644 --- a/public/scripts/preset-manager.js +++ b/public/scripts/preset-manager.js @@ -103,6 +103,40 @@ function ensurePresetLinkedLorebookButton(apiId) { $anchor.after($button); } +function ensurePresetEmbeddedRegexImportButton(apiId) { + if ($(`[data-preset-manager-reimport-regex="${apiId}"]`).length) { + return; + } + + const $anchor = $(`[data-preset-manager-linked-lorebook="${apiId}"], [data-preset-manager-restore="${apiId}"], [data-preset-manager-new="${apiId}"], [data-preset-manager-update="${apiId}"], [data-preset-manager-rename="${apiId}"]`).last(); + if (!$anchor.length) { + return; + } + + const title = t`Import embedded regex scripts`; + const tagName = String($anchor.prop('tagName') || '').toUpperCase(); + let $button; + + if (tagName === 'I') { + $button = $('') + .addClass('menu_button fa-solid fa-file-import') + .attr('title', title); + } else { + $button = $('
    ') + .addClass('menu_button menu_button_icon') + .attr('title', title) + .append($('').addClass('fa-solid fa-file-import')); + if ($anchor.hasClass('margin0')) { + $button.addClass('margin0'); + } + } + + $button + .attr('data-i18n', '[title]Import embedded regex scripts') + .attr('data-preset-manager-reimport-regex', apiId); + $anchor.after($button); +} + async function promptLorebookNameToBind(worldNames, defaultName = '') { const names = Array.isArray(worldNames) ? worldNames : []; if (!names.length) { @@ -386,6 +420,7 @@ function registerPresetManagers() { console.debug(`Registering preset manager for API: ${apiId}`); presetManagers[apiId] = new PresetManager($(e), apiId); ensurePresetLinkedLorebookButton(apiId); + ensurePresetEmbeddedRegexImportButton(apiId); primaryManager ??= presetManagers[apiId]; } @@ -1916,6 +1951,12 @@ export async function initPresetManager() { await managePresetLinkedLorebook(apiId); }); + $(document).on('click', '[data-preset-manager-reimport-regex]', async function () { + const apiId = $(this).data('preset-manager-reimport-regex'); + const { reimportPresetEmbeddedRegexScripts } = await import('./extensions/regex/index.js'); + await reimportPresetEmbeddedRegexScripts(apiId); + }); + $(document).on('change', '[data-preset-manager-file]', async function (e) { const apiId = $(this).data('preset-manager-file'); const presetManager = getPresetManager(apiId); From 5ef478457b49c9b92124fd9bc16f913829709034 Mon Sep 17 00:00:00 2001 From: 1432647 <1432647@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:08:16 +0800 Subject: [PATCH 7/7] style(preset-manager): fix eslint indentation and brace style --- public/scripts/preset-manager.js | 69 ++++++++++++++++---------------- 1 file changed, 34 insertions(+), 35 deletions(-) diff --git a/public/scripts/preset-manager.js b/public/scripts/preset-manager.js index 22b17976c..c2138cc39 100644 --- a/public/scripts/preset-manager.js +++ b/public/scripts/preset-manager.js @@ -437,29 +437,29 @@ function registerPresetManagers() { width: '100%', }, presetGroupCallbacks: { - getGroups: () => primaryManager.getPresetGroups(), - getGroupForPreset: (name) => primaryManager.getGroupForPreset(name), - getGroupDepth: (groupId) => primaryManager.getGroupDepth(groupId), - rebuild: () => { - primaryManager.rebuildSelectWithGroups(); - }, - createGroup: async (name, parentId) => { - const id = primaryManager.createPresetGroup(name, parentId || null); - return id; - }, - renameGroup: async (groupId, newName) => { - primaryManager.renamePresetGroup(groupId, newName); - }, - deleteGroup: async (groupId) => { - primaryManager.deletePresetGroup(groupId); - }, - addToGroup: async (presetName, groupId) => { - primaryManager.addPresetToGroup(presetName, groupId); - }, - removeFromGroup: async (presetName) => { - primaryManager.removePresetFromGroup(presetName); - }, - }, + getGroups: () => primaryManager.getPresetGroups(), + getGroupForPreset: (name) => primaryManager.getGroupForPreset(name), + getGroupDepth: (groupId) => primaryManager.getGroupDepth(groupId), + rebuild: () => { + primaryManager.rebuildSelectWithGroups(); + }, + createGroup: async (name, parentId) => { + const id = primaryManager.createPresetGroup(name, parentId || null); + return id; + }, + renameGroup: async (groupId, newName) => { + primaryManager.renamePresetGroup(groupId, newName); + }, + deleteGroup: async (groupId) => { + primaryManager.deletePresetGroup(groupId); + }, + addToGroup: async (presetName, groupId) => { + primaryManager.addPresetToGroup(presetName, groupId); + }, + removeFromGroup: async (presetName) => { + primaryManager.removePresetFromGroup(presetName); + }, + }, }); } }); @@ -1025,8 +1025,7 @@ class PresetManager { $(this.select).find(`option[value="${name}"]`).prop('selected', true); $(this.select).val(name).trigger('change'); } - } - else { + } else { const value = preset_names[name]; presets[value] = preset; if (select) { @@ -1661,16 +1660,16 @@ class PresetManager { const prefix = depth > 0 ? '└ ' : ''; const $header = $('') - .val(`__group_header__${group.id}`) - .text(indent + prefix + group.name) - .attr('data-preset-group-id', group.id) - .attr('data-preset-group-header', 'true') - .attr('data-depth', depth); - if (group.parentId) { - $header.attr('data-preset-group-parent-id', group.parentId); - } - $header.prop('disabled', true); - $select.append($header); + .val(`__group_header__${group.id}`) + .text(indent + prefix + group.name) + .attr('data-preset-group-id', group.id) + .attr('data-preset-group-header', 'true') + .attr('data-depth', depth); + if (group.parentId) { + $header.attr('data-preset-group-parent-id', group.parentId); + } + $header.prop('disabled', true); + $select.append($header); for (const presetName of group.presets) { const opt = allOptions.find(o => o.text === presetName);