mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
fix(autocomplete): strip lastAccepted boundary from exported workflows (#1093)
The hidden __lm_autocomplete_meta_* widget persisted lastAccepted (insertedText/textSnapshot) into exported workflow JSON, leaking old prompt text even after the user deleted it. Patch app.graphToPrompt (shared by workflow export, Export API and queueing) to strip lastAccepted from the serialized result's widgets_values / widgets_values_named / output inputs. Only the exported artifact is touched; live node state, undo snapshots, copy/paste and local saves keep the boundary intact.
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
API_MODULE,
|
||||
APP_MODULE,
|
||||
CARET_HELPER_MODULE,
|
||||
PREVIEW_COMPONENT_MODULE,
|
||||
AUTOCOMPLETE_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
|
||||
APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
|
||||
CARET_HELPER_MODULE: new URL('../../../web/comfyui/textarea_caret_helper.js', import.meta.url).pathname,
|
||||
PREVIEW_COMPONENT_MODULE: new URL('../../../web/comfyui/preview_tooltip.js', import.meta.url).pathname,
|
||||
AUTOCOMPLETE_MODULE: new URL('../../../web/comfyui/autocomplete.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
vi.mock(API_MODULE, () => ({
|
||||
api: { fetchApi: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock(APP_MODULE, () => ({
|
||||
app: {
|
||||
canvas: { ds: { scale: 1 } },
|
||||
extensionManager: {
|
||||
setting: { get: vi.fn(), set: vi.fn() },
|
||||
},
|
||||
registerExtension: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock(CARET_HELPER_MODULE, () => ({
|
||||
TextAreaCaretHelper: vi.fn(() => ({
|
||||
getBeforeCursor: vi.fn(() => ''),
|
||||
getCursorOffset: vi.fn(() => ({ left: 0, top: 0 })),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock(PREVIEW_COMPONENT_MODULE, () => ({
|
||||
PreviewTooltip: vi.fn(() => ({ show: vi.fn(), hide: vi.fn(), cleanup: vi.fn() })),
|
||||
}));
|
||||
|
||||
const METADATA_NAME = '__lm_autocomplete_meta_text';
|
||||
|
||||
function makeMetadataValue() {
|
||||
return {
|
||||
version: 1,
|
||||
textWidgetName: 'text',
|
||||
lastAccepted: {
|
||||
start: 0,
|
||||
end: 6,
|
||||
insertedText: '1girl ',
|
||||
textSnapshot: 'old prompt text, 1girl ',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('stripAutocompleteLastAccepted', () => {
|
||||
let stripAutocompleteLastAccepted;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await import(AUTOCOMPLETE_MODULE);
|
||||
stripAutocompleteLastAccepted = module.stripAutocompleteLastAccepted;
|
||||
});
|
||||
|
||||
it('removes lastAccepted while keeping the metadata base fields', () => {
|
||||
const value = makeMetadataValue();
|
||||
const stripped = stripAutocompleteLastAccepted(value);
|
||||
|
||||
expect(stripped).toEqual({ version: 1, textWidgetName: 'text' });
|
||||
expect('lastAccepted' in stripped).toBe(false);
|
||||
// Original value must not be mutated
|
||||
expect(value.lastAccepted).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns values without lastAccepted as-is (same reference)', () => {
|
||||
const value = { version: 1, textWidgetName: 'text' };
|
||||
expect(stripAutocompleteLastAccepted(value)).toBe(value);
|
||||
});
|
||||
|
||||
it('returns non-object values as-is', () => {
|
||||
expect(stripAutocompleteLastAccepted(null)).toBe(null);
|
||||
expect(stripAutocompleteLastAccepted(undefined)).toBe(undefined);
|
||||
expect(stripAutocompleteLastAccepted('text')).toBe('text');
|
||||
expect(stripAutocompleteLastAccepted([1, 2])).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripAutocompleteMetadataFromPromptResult', () => {
|
||||
let stripResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await import(AUTOCOMPLETE_MODULE);
|
||||
stripResult = module.stripAutocompleteMetadataFromPromptResult;
|
||||
});
|
||||
|
||||
function makeWorkflowNode() {
|
||||
const metadataValue = makeMetadataValue();
|
||||
return {
|
||||
properties: { __lm_widget_ids: ['text', METADATA_NAME] },
|
||||
widgets_values: ['current text', metadataValue],
|
||||
widgets_values_named: {
|
||||
text: 'current text',
|
||||
[METADATA_NAME]: metadataValue,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('strips lastAccepted from workflow widgets_values using __lm_widget_ids alignment', () => {
|
||||
const result = {
|
||||
workflow: { nodes: [makeWorkflowNode()] },
|
||||
output: {},
|
||||
};
|
||||
|
||||
const returned = stripResult(result);
|
||||
|
||||
expect(returned).toBe(result);
|
||||
expect(result.workflow.nodes[0].widgets_values[1])
|
||||
.toEqual({ version: 1, textWidgetName: 'text' });
|
||||
});
|
||||
|
||||
it('strips lastAccepted from widgets_values_named and leaves other widgets untouched', () => {
|
||||
const result = {
|
||||
workflow: { nodes: [makeWorkflowNode()] },
|
||||
output: {},
|
||||
};
|
||||
|
||||
stripResult(result);
|
||||
|
||||
const node = result.workflow.nodes[0];
|
||||
expect(node.widgets_values_named[METADATA_NAME])
|
||||
.toEqual({ version: 1, textWidgetName: 'text' });
|
||||
expect(node.widgets_values_named.text).toBe('current text');
|
||||
expect(node.widgets_values[0]).toBe('current text');
|
||||
});
|
||||
|
||||
it('handles null entries in widgets_values (bypass compatibility padding)', () => {
|
||||
const node = makeWorkflowNode();
|
||||
node.properties.__lm_widget_ids = ['text', 'seed', METADATA_NAME];
|
||||
node.widgets_values = ['current text', null, makeMetadataValue()];
|
||||
const result = { workflow: { nodes: [node] }, output: {} };
|
||||
|
||||
stripResult(result);
|
||||
|
||||
expect(result.workflow.nodes[0].widgets_values[1]).toBe(null);
|
||||
expect(result.workflow.nodes[0].widgets_values[2])
|
||||
.toEqual({ version: 1, textWidgetName: 'text' });
|
||||
});
|
||||
|
||||
it('still strips widgets_values_named when __lm_widget_ids is missing (legacy files)', () => {
|
||||
const node = makeWorkflowNode();
|
||||
delete node.properties;
|
||||
const arrayValue = node.widgets_values[1];
|
||||
const result = { workflow: { nodes: [node] }, output: {} };
|
||||
|
||||
stripResult(result);
|
||||
|
||||
// Array entries cannot be located without widget ids — left untouched
|
||||
expect(result.workflow.nodes[0].widgets_values[1]).toBe(arrayValue);
|
||||
expect(result.workflow.nodes[0].widgets_values_named[METADATA_NAME])
|
||||
.toEqual({ version: 1, textWidgetName: 'text' });
|
||||
});
|
||||
|
||||
it('strips lastAccepted from output (API prompt) inputs', () => {
|
||||
const result = {
|
||||
workflow: { nodes: [] },
|
||||
output: {
|
||||
'7': {
|
||||
class_type: 'Prompt (LoraManager)',
|
||||
inputs: {
|
||||
text: 'current text',
|
||||
[METADATA_NAME]: makeMetadataValue(),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
stripResult(result);
|
||||
|
||||
const inputs = result.output['7'].inputs;
|
||||
expect(inputs[METADATA_NAME]).toEqual({ version: 1, textWidgetName: 'text' });
|
||||
expect(inputs.text).toBe('current text');
|
||||
});
|
||||
|
||||
it('strips lastAccepted inside subgraph definitions', () => {
|
||||
const result = {
|
||||
workflow: {
|
||||
nodes: [],
|
||||
definitions: {
|
||||
subgraphs: [{ nodes: [makeWorkflowNode()] }],
|
||||
},
|
||||
},
|
||||
output: {},
|
||||
};
|
||||
|
||||
stripResult(result);
|
||||
|
||||
const subgraphNode = result.workflow.definitions.subgraphs[0].nodes[0];
|
||||
expect(subgraphNode.widgets_values_named[METADATA_NAME])
|
||||
.toEqual({ version: 1, textWidgetName: 'text' });
|
||||
});
|
||||
|
||||
it('leaves results without lastAccepted unchanged', () => {
|
||||
const metadataValue = { version: 1, textWidgetName: 'text' };
|
||||
const result = {
|
||||
workflow: {
|
||||
nodes: [{
|
||||
properties: { __lm_widget_ids: ['text', METADATA_NAME] },
|
||||
widgets_values: ['abc', metadataValue],
|
||||
widgets_values_named: { text: 'abc', [METADATA_NAME]: metadataValue },
|
||||
}],
|
||||
},
|
||||
output: {
|
||||
'1': { inputs: { text: 'abc', [METADATA_NAME]: metadataValue } },
|
||||
},
|
||||
};
|
||||
|
||||
stripResult(result);
|
||||
|
||||
expect(result.workflow.nodes[0].widgets_values[1]).toBe(metadataValue);
|
||||
expect(result.output['1'].inputs[METADATA_NAME]).toBe(metadataValue);
|
||||
});
|
||||
|
||||
it('tolerates malformed results', () => {
|
||||
expect(stripResult(null)).toBe(null);
|
||||
expect(stripResult(undefined)).toBe(undefined);
|
||||
expect(stripResult({})).toEqual({});
|
||||
|
||||
const result = {
|
||||
workflow: { nodes: [null, { widgets_values: null }] },
|
||||
output: { '1': { inputs: null }, '2': {} },
|
||||
};
|
||||
expect(() => stripResult(result)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,23 @@ import { app } from '../../../scripts/app.js'
|
||||
import { api } from '../../../scripts/api.js'
|
||||
// @ts-ignore
|
||||
import { getPoolConfigFromConnectedNode, getActiveLorasFromNode, updateConnectedTriggerWords, updateDownstreamLoaders } from '../../web/comfyui/utils.js'
|
||||
// @ts-ignore
|
||||
import { stripAutocompleteMetadataFromPromptResult } from '../../web/comfyui/autocomplete.js'
|
||||
|
||||
// Strip the autocomplete lastAccepted boundary from exported workflows.
|
||||
// lastAccepted carries old prompt text (insertedText/textSnapshot) and is
|
||||
// session-only state; it must not leak into exported JSON (#1093).
|
||||
// graphToPrompt is shared by workflow export, Export API and queueing.
|
||||
// Local saves go through the change-tracker snapshot (no graphToPrompt) and
|
||||
// are intentionally left untouched so cross-session caret continuity is kept.
|
||||
// Post-processing the resolved result avoids any window/race with
|
||||
// change-tracker or copy/paste serialization of live state.
|
||||
const originalGraphToPrompt = app.graphToPrompt.bind(app)
|
||||
app.graphToPrompt = async (...args: unknown[]) => {
|
||||
const result = await originalGraphToPrompt(...args)
|
||||
stripAutocompleteMetadataFromPromptResult(result)
|
||||
return result
|
||||
}
|
||||
|
||||
function forwardMiddleMouseToCanvas(container: HTMLElement) {
|
||||
if (!container) return
|
||||
|
||||
@@ -278,6 +278,112 @@ function createAutocompleteMetadataBase(textWidgetName = 'text') {
|
||||
};
|
||||
}
|
||||
|
||||
const AUTOCOMPLETE_METADATA_WIDGET_PREFIX = '__lm_autocomplete_meta_';
|
||||
const LORA_MANAGER_WIDGET_IDS_PROPERTY = '__lm_widget_ids'; // Must match vue-widgets/src/main.ts
|
||||
|
||||
/**
|
||||
* Return a copy of an autocomplete metadata value without the lastAccepted
|
||||
* boundary. lastAccepted carries insertedText/textSnapshot (old prompt text)
|
||||
* and is session-only state; it must not leak into exported workflow JSON.
|
||||
* Values without lastAccepted are returned as-is.
|
||||
*
|
||||
* @param {*} value - Widget metadata value (or any other widget value)
|
||||
* @returns {*} The stripped copy, or the original value when untouched
|
||||
*/
|
||||
export function stripAutocompleteLastAccepted(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
if (!('lastAccepted' in value)) {
|
||||
return value;
|
||||
}
|
||||
const stripped = { ...value };
|
||||
delete stripped.lastAccepted;
|
||||
return stripped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip lastAccepted from autocomplete metadata widgets on a serialized
|
||||
* node's widgets_values / widgets_values_named. Array entries are aligned
|
||||
* via properties.__lm_widget_ids (written by the extension's onSerialize).
|
||||
* Operates on graph.serialize() output, which is already a deep copy.
|
||||
*
|
||||
* @param {Array} nodes - Serialized node array
|
||||
*/
|
||||
function stripAutocompleteMetadataFromNodes(nodes) {
|
||||
if (!Array.isArray(nodes)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!node || typeof node !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const widgetIds = node.properties?.[LORA_MANAGER_WIDGET_IDS_PROPERTY];
|
||||
if (Array.isArray(node.widgets_values) && Array.isArray(widgetIds)) {
|
||||
for (let i = 0; i < node.widgets_values.length && i < widgetIds.length; i++) {
|
||||
if (typeof widgetIds[i] === 'string'
|
||||
&& widgetIds[i].startsWith(AUTOCOMPLETE_METADATA_WIDGET_PREFIX)) {
|
||||
node.widgets_values[i] = stripAutocompleteLastAccepted(node.widgets_values[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const named = node.widgets_values_named;
|
||||
if (named && typeof named === 'object') {
|
||||
for (const [key, value] of Object.entries(named)) {
|
||||
if (key.startsWith(AUTOCOMPLETE_METADATA_WIDGET_PREFIX)) {
|
||||
named[key] = stripAutocompleteLastAccepted(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip lastAccepted from autocomplete metadata widgets in a graphToPrompt()
|
||||
* result (both the workflow document and the API prompt). Used by the widget
|
||||
* bundle to keep exported workflows free of old prompt text while leaving
|
||||
* live node state untouched.
|
||||
*
|
||||
* @param {*} result - graphToPrompt() result: { workflow, output }
|
||||
* @returns {*} The same result object, with metadata entries replaced in place
|
||||
*/
|
||||
export function stripAutocompleteMetadataFromPromptResult(result) {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return result;
|
||||
}
|
||||
|
||||
const workflow = result.workflow;
|
||||
if (workflow && typeof workflow === 'object') {
|
||||
stripAutocompleteMetadataFromNodes(workflow.nodes);
|
||||
const subgraphs = workflow.definitions?.subgraphs;
|
||||
if (Array.isArray(subgraphs)) {
|
||||
for (const subgraph of subgraphs) {
|
||||
stripAutocompleteMetadataFromNodes(subgraph?.nodes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const output = result.output;
|
||||
if (output && typeof output === 'object') {
|
||||
for (const nodeOutput of Object.values(output)) {
|
||||
const inputs = nodeOutput?.inputs;
|
||||
if (!inputs || typeof inputs !== 'object') {
|
||||
continue;
|
||||
}
|
||||
for (const [key, value] of Object.entries(inputs)) {
|
||||
if (key.startsWith(AUTOCOMPLETE_METADATA_WIDGET_PREFIX)) {
|
||||
inputs[key] = stripAutocompleteLastAccepted(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function createDefaultBehavior(modelType) {
|
||||
return {
|
||||
enablePreview: false,
|
||||
|
||||
@@ -15795,6 +15795,77 @@ function _initLoraSyntaxFormatReactive() {
|
||||
});
|
||||
}
|
||||
_initLoraSyntaxFormatReactive();
|
||||
const AUTOCOMPLETE_METADATA_WIDGET_PREFIX = "__lm_autocomplete_meta_";
|
||||
const LORA_MANAGER_WIDGET_IDS_PROPERTY$1 = "__lm_widget_ids";
|
||||
function stripAutocompleteLastAccepted(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
if (!("lastAccepted" in value)) {
|
||||
return value;
|
||||
}
|
||||
const stripped = { ...value };
|
||||
delete stripped.lastAccepted;
|
||||
return stripped;
|
||||
}
|
||||
function stripAutocompleteMetadataFromNodes(nodes) {
|
||||
var _a2;
|
||||
if (!Array.isArray(nodes)) {
|
||||
return;
|
||||
}
|
||||
for (const node of nodes) {
|
||||
if (!node || typeof node !== "object") {
|
||||
continue;
|
||||
}
|
||||
const widgetIds = (_a2 = node.properties) == null ? void 0 : _a2[LORA_MANAGER_WIDGET_IDS_PROPERTY$1];
|
||||
if (Array.isArray(node.widgets_values) && Array.isArray(widgetIds)) {
|
||||
for (let i2 = 0; i2 < node.widgets_values.length && i2 < widgetIds.length; i2++) {
|
||||
if (typeof widgetIds[i2] === "string" && widgetIds[i2].startsWith(AUTOCOMPLETE_METADATA_WIDGET_PREFIX)) {
|
||||
node.widgets_values[i2] = stripAutocompleteLastAccepted(node.widgets_values[i2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
const named = node.widgets_values_named;
|
||||
if (named && typeof named === "object") {
|
||||
for (const [key, value] of Object.entries(named)) {
|
||||
if (key.startsWith(AUTOCOMPLETE_METADATA_WIDGET_PREFIX)) {
|
||||
named[key] = stripAutocompleteLastAccepted(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function stripAutocompleteMetadataFromPromptResult(result) {
|
||||
var _a2;
|
||||
if (!result || typeof result !== "object") {
|
||||
return result;
|
||||
}
|
||||
const workflow = result.workflow;
|
||||
if (workflow && typeof workflow === "object") {
|
||||
stripAutocompleteMetadataFromNodes(workflow.nodes);
|
||||
const subgraphs = (_a2 = workflow.definitions) == null ? void 0 : _a2.subgraphs;
|
||||
if (Array.isArray(subgraphs)) {
|
||||
for (const subgraph of subgraphs) {
|
||||
stripAutocompleteMetadataFromNodes(subgraph == null ? void 0 : subgraph.nodes);
|
||||
}
|
||||
}
|
||||
}
|
||||
const output = result.output;
|
||||
if (output && typeof output === "object") {
|
||||
for (const nodeOutput of Object.values(output)) {
|
||||
const inputs = nodeOutput == null ? void 0 : nodeOutput.inputs;
|
||||
if (!inputs || typeof inputs !== "object") {
|
||||
continue;
|
||||
}
|
||||
for (const [key, value] of Object.entries(inputs)) {
|
||||
if (key.startsWith(AUTOCOMPLETE_METADATA_WIDGET_PREFIX)) {
|
||||
inputs[key] = stripAutocompleteLastAccepted(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const ROOT_GRAPH_ID = "root";
|
||||
const LORA_PROVIDER_NODE_TYPES = [
|
||||
"Lora Stacker (LoraManager)",
|
||||
@@ -16037,6 +16108,12 @@ const AUTOCOMPLETE_TEXT_MIN_WIDTH_DEFAULT = 400;
|
||||
const AUTOCOMPLETE_TEXT_MIN_HEIGHT_DEFAULT = 300;
|
||||
const AUTOCOMPLETE_METADATA_VERSION = 1;
|
||||
const LORA_MANAGER_WIDGET_IDS_PROPERTY = "__lm_widget_ids";
|
||||
const originalGraphToPrompt = app$1.graphToPrompt.bind(app$1);
|
||||
app$1.graphToPrompt = async (...args) => {
|
||||
const result = await originalGraphToPrompt(...args);
|
||||
stripAutocompleteMetadataFromPromptResult(result);
|
||||
return result;
|
||||
};
|
||||
function forwardMiddleMouseToCanvas(container) {
|
||||
if (!container) return;
|
||||
container.addEventListener("pointerdown", (event) => {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user