fix(lora-loader): preserve repeated spaces inside lora names

Whitespace cleanup in cleanupLoraSyntax() and the autocomplete blur
formatter collapsed all whitespace runs, including inside <lora:...>
tags. A file named 'test -  0021.safetensors' was rewritten to
'test - 0021' in the node text, so runtime file resolution failed.

Protect lora tags with placeholders (or segment splitting) so only
whitespace between entries is normalized; names inside tags are kept
byte-for-byte.
This commit is contained in:
Will Miao
2026-09-07 19:20:15 +08:00
parent 82b34097fb
commit 53fa22f39c
4 changed files with 106 additions and 3 deletions
@@ -0,0 +1,58 @@
import { describe, it, expect, vi } from 'vitest';
const {
API_MODULE,
APP_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,
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(),
},
}));
describe('formatAutocompleteTextOnBlur', () => {
it('preserves repeated spaces inside LoRA names', async () => {
const { formatAutocompleteTextOnBlur } = await import(AUTOCOMPLETE_MODULE);
expect(formatAutocompleteTextOnBlur('<lora:test - 0021:1.00>')).toBe(
'<lora:test - 0021:1.00>'
);
});
it('preserves repeated spaces across multiple LoRA entries', async () => {
const { formatAutocompleteTextOnBlur } = await import(AUTOCOMPLETE_MODULE);
expect(
formatAutocompleteTextOnBlur('<lora:test - 0021:1.00>,<lora:a b:0.50>')
).toBe('<lora:test - 0021:1.00>, <lora:a b:0.50>');
});
it('still normalizes whitespace outside LoRA tags', async () => {
const { formatAutocompleteTextOnBlur } = await import(AUTOCOMPLETE_MODULE);
expect(formatAutocompleteTextOnBlur('masterpiece, best quality')).toBe(
'masterpiece, best quality'
);
});
});
@@ -40,6 +40,15 @@ describe("applyLoraValuesToText", () => {
expect(result).toBe("<lora:Expanded:1.00:1.00>"); expect(result).toBe("<lora:Expanded:1.00:1.00>");
}); });
it("preserves repeated spaces inside LoRA names", () => {
const original = "<lora:test - 0021:1.00>";
const result = applyLoraValuesToText(original, [
{ name: "test - 0021", strength: 0.5 }
]);
expect(result).toBe("<lora:test - 0021:0.50>");
});
}); });
describe("normalizeStrengthValue", () => { describe("normalizeStrengthValue", () => {
@@ -74,6 +83,18 @@ describe("cleanupLoraSyntax", () => {
it("collapses whitespace and stray commas", () => { it("collapses whitespace and stray commas", () => {
expect(cleanupLoraSyntax(" <lora:A:1.00> , ," )).toBe("<lora:A:1.00>"); expect(cleanupLoraSyntax(" <lora:A:1.00> , ," )).toBe("<lora:A:1.00>");
}); });
it("preserves repeated spaces inside LoRA names", () => {
expect(cleanupLoraSyntax("<lora:test - 0021:1.00> , ,")).toBe(
"<lora:test - 0021:1.00>"
);
});
it("still normalizes whitespace between entries", () => {
expect(
cleanupLoraSyntax(" <lora:A:1.00> <lora:test - 0021:0.50> ")
).toBe("<lora:A:1.00> <lora:test - 0021:0.50>");
});
}); });
describe("debounce", () => { describe("debounce", () => {
+13 -1
View File
@@ -227,8 +227,20 @@ function formatAutocompleteInsertion(text = '') {
return getAutocompleteAppendCommaPreference() ? `${trimmed},` : `${trimmed} `; return getAutocompleteAppendCommaPreference() ? `${trimmed},` : `${trimmed} `;
} }
// Matches a complete <lora:name:strength[:clip_strength]> tag. Kept
// permissive on the strength fields (mirrors the backend parser) so tags
// are still protected while the user is mid-edit.
const LORA_TAG_PATTERN = /(<lora:[^:>]+:[^:>]+(?::[^:>]+)?>)/gi;
function normalizeAutocompleteSegment(segment = '') { function normalizeAutocompleteSegment(segment = '') {
return segment.replace(/\s+/g, ' ').trim(); // Collapse whitespace only outside <lora:...> tags: names inside the tags
// may legitimately contain repeated spaces (e.g. "test - 0021"), and
// collapsing them breaks file resolution at runtime.
return segment
.split(LORA_TAG_PATTERN)
.map((part, index) => (index % 2 === 1 ? part : part.replace(/\s+/g, ' ')))
.join('')
.trim();
} }
export function formatAutocompleteTextOnBlur(text = '') { export function formatAutocompleteTextOnBlur(text = '') {
+14 -2
View File
@@ -38,7 +38,17 @@ function cleanupLoraSyntax(text) {
return ""; return "";
} }
let cleaned = text // Protect <lora:...> tags with placeholders before cleanup: names inside
// the tags may legitimately contain repeated spaces or commas (e.g.
// "test - 0021"), and collapsing them breaks file resolution at runtime.
const protectedTags = [];
LORA_PATTERN.lastIndex = 0;
const masked = text.replace(LORA_PATTERN, (match) => {
protectedTags.push(match);
return `\u0000${protectedTags.length - 1}\u0000`;
});
let cleaned = masked
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.replace(/,\s*,+/g, ",") .replace(/,\s*,+/g, ",")
.replace(/\s*,\s*/g, ",") .replace(/\s*,\s*/g, ",")
@@ -51,7 +61,9 @@ function cleanupLoraSyntax(text) {
cleaned = cleaned.replace(/(^,)|(,$)/g, ""); cleaned = cleaned.replace(/(^,)|(,$)/g, "");
cleaned = cleaned.replace(/,\s*/g, ", "); cleaned = cleaned.replace(/,\s*/g, ", ");
return cleaned.trim(); return cleaned
.trim()
.replace(/\u0000(\d+)\u0000/g, (_, index) => protectedTags[Number(index)]);
} }
export function applyLoraValuesToText(originalText, loras) { export function applyLoraValuesToText(originalText, loras) {