Support .txt workflow inspection and unify prompt comparison text field colors

This commit is contained in:
Dariusz L
2026-10-28 00:33:34 +01:00
parent 44621b2a17
commit 22f454b67f
5 changed files with 199 additions and 31 deletions
+21 -1
View File
@@ -643,9 +643,29 @@ def inspect_image_bytes(
def _parse_workflow_json_text(value: str) -> Any:
if len(value.encode("utf-8")) > MAX_WORKFLOW_JSON_BYTES:
raise ValueError("Workflow JSON is too large")
text = value.lstrip("\ufeff").strip()
if text.startswith("```"):
first_newline = text.find("\n")
last_fence = text.rfind("```")
if first_newline != -1 and last_fence > first_newline:
text = text[first_newline + 1 : last_fence].strip()
try:
return json.loads(value)
return json.loads(text)
except (TypeError, ValueError, json.JSONDecodeError) as exc:
first_brace = text.find("{")
last_brace = text.rfind("}")
if first_brace != -1 and last_brace > first_brace:
try:
return json.loads(text[first_brace : last_brace + 1])
except (TypeError, ValueError, json.JSONDecodeError):
pass
first_bracket = text.find("[")
last_bracket = text.rfind("]")
if first_bracket != -1 and last_bracket > first_bracket:
try:
return json.loads(text[first_bracket : last_bracket + 1])
except (TypeError, ValueError, json.JSONDecodeError):
pass
raise ValueError("Workflow JSON is not valid JSON") from exc
+13
View File
@@ -185,6 +185,19 @@ def test_json_inspection_accepts_ui_and_api_workflows():
assert api_result["workflow_node_count"] == 2
def test_json_inspection_accepts_fenced_markdown_and_bom_workflow_text():
raw_ui = '```json\n{"nodes": [{"id": 1, "type": "CheckpointLoaderSimple", "widgets_values": []}]}\n```'
result = inspect_workflow_json(raw_ui, filename="workflow.txt")
assert result["workflow_format"] == UI_WORKFLOW_FORMAT
assert result["workflow_node_count"] == 1
assert result["filename"] == "workflow.txt"
bom_api = "\ufeff" + json.dumps(api_workflow())
bom_result = inspect_workflow_json(bom_api, filename="api_workflow.txt")
assert bom_result["workflow_format"] == API_WORKFLOW_FORMAT
assert bom_result["workflow_node_count"] == 2
def test_json_inspection_rejects_unrelated_json():
with pytest.raises(ValueError, match="Unsupported workflow JSON"):
inspect_workflow_json({"settings": {"theme": "dark"}})
+79
View File
@@ -308,6 +308,85 @@ test('handleImageMetadataPaste inspects workflow JSON file from clipboard', () =
assert.strictEqual(inspectedWorkflowFile, jsonFile);
});
test('handleImageMetadataPaste inspects workflow TXT file from clipboard', () => {
const ctx = createMockContext();
const { root } = createMockPasteTarget(ctx);
let inspectedWorkflowFile = null;
ctx.inspectWorkflowFile = (file) => { inspectedWorkflowFile = file; };
const txtFile = { name: 'my_workflow.txt', type: 'text/plain', size: 512 };
const event = {
target: root,
preventDefault: () => {},
stopPropagation: () => {},
clipboardData: { files: [txtFile], items: [] },
};
assert.strictEqual(ctx.handleImageMetadataPaste(event), true);
assert.strictEqual(inspectedWorkflowFile, txtFile);
});
test('inspectImageMetadataFile routes .txt file to inspectWorkflowFile', async () => {
const ctx = createMockContext();
let routedWorkflowFile = null;
ctx.inspectWorkflowFile = (file) => { routedWorkflowFile = file; return 'routed'; };
const txtFile = { name: 'workflow.txt', type: 'text/plain', size: 256 };
const res = await ctx.inspectImageMetadataFile(txtFile);
assert.strictEqual(res, 'routed');
assert.strictEqual(routedWorkflowFile, txtFile);
});
test('inspectWorkflowFile parses JSON workflow from .txt file', async () => {
const ctx = createMockContext();
let inspectedPayload = null;
ctx.runImageMetadataInspection = (payload) => { inspectedPayload = payload; return payload; };
const workflowObj = { nodes: [{ id: 1, type: 'KSampler' }] };
const txtFile = {
name: 'workflow_export.txt',
type: 'text/plain',
size: 256,
text: async () => JSON.stringify(workflowObj),
};
const res = await ctx.inspectWorkflowFile(txtFile);
assert.ok(res);
assert.strictEqual(inspectedPayload?.source_type, 'workflow');
assert.strictEqual(inspectedPayload?.filename, 'workflow_export.txt');
assert.deepStrictEqual(inspectedPayload?.workflow, workflowObj);
});
test('inspectWorkflowFile parses markdown fenced JSON workflow from .txt file', async () => {
const ctx = createMockContext();
let inspectedPayload = null;
ctx.runImageMetadataInspection = (payload) => { inspectedPayload = payload; return payload; };
const workflowObj = { nodes: [{ id: 2, type: 'VAELoader' }] };
const fencedContent = '```json\n' + JSON.stringify(workflowObj) + '\n```';
const txtFile = {
name: 'shared_workflow.txt',
type: 'text/plain',
size: 512,
text: async () => fencedContent,
};
const res = await ctx.inspectWorkflowFile(txtFile);
assert.ok(res);
assert.deepStrictEqual(inspectedPayload?.workflow, workflowObj);
});
test('inspectWorkflowFile shows error notification when .txt file does not contain valid JSON', async () => {
const ctx = createMockContext();
let notification = null;
ctx.showNotification = (msg, type) => { notification = { msg, type }; };
const txtFile = {
name: 'notes.txt',
type: 'text/plain',
size: 100,
text: async () => 'This is just a simple text note, not a JSON workflow.',
};
const res = await ctx.inspectWorkflowFile(txtFile);
assert.strictEqual(res, null);
assert.deepStrictEqual(notification, {
msg: 'The text file does not contain valid workflow JSON.',
type: 'error',
});
});
test('handleImageMetadataPaste inspects workflow JSON text from clipboard', () => {
const ctx = createMockContext();
const { root, textarea } = createMockPasteTarget(ctx);
+1 -1
View File
@@ -5437,7 +5437,7 @@ button.mr-info-link:disabled {
padding: var(--mr-spacing-4) var(--mr-spacing-8);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--mr-radius-md, var(--mr-radius-lg));
background: rgba(0, 0, 0, 0.16);
background: rgba(255, 255, 255, 0.045);
color: var(--mr-text);
font-family: var(--mr-font-family-mono);
font-size: var(--mr-font-xs);
@@ -120,7 +120,61 @@ function isWorkflowFile(file) {
const name = String(file?.name || '').toLowerCase();
return file?.type === 'application/json'
|| file?.type === 'text/json'
|| name.endsWith('.json');
|| file?.type === 'text/plain'
|| name.endsWith('.json')
|| name.endsWith('.txt');
}
function readFileAsText(file) {
if (typeof file?.text === 'function') {
return file.text();
}
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error('Could not read the workflow file.'));
reader.readAsText(file);
});
}
export function extractJsonFromText(rawText) {
if (!rawText || typeof rawText !== 'string') return null;
let text = rawText.replace(/^\uFEFF/, '').trim();
if (!text) return null;
if (text.startsWith('```')) {
const firstLineEnd = text.indexOf('\n');
if (firstLineEnd !== -1) {
const lastFence = text.lastIndexOf('```');
if (lastFence > firstLineEnd) {
text = text.slice(firstLineEnd + 1, lastFence).trim();
}
}
}
try {
return JSON.parse(text);
} catch {
const firstBrace = text.indexOf('{');
const lastBrace = text.lastIndexOf('}');
if (firstBrace !== -1 && lastBrace > firstBrace) {
try {
return JSON.parse(text.slice(firstBrace, lastBrace + 1));
} catch {
// Ignore nested extraction failure.
}
}
const firstBracket = text.indexOf('[');
const lastBracket = text.lastIndexOf(']');
if (firstBracket !== -1 && lastBracket > firstBracket) {
try {
return JSON.parse(text.slice(firstBracket, lastBracket + 1));
} catch {
// Ignore nested extraction failure.
}
}
return null;
}
}
function readFileAsDataUrl(file) {
@@ -385,12 +439,12 @@ export const imageMetadataMethods = {
<span class="mr-section-collapse-chevron" aria-hidden="true">${getSvgIcon('chevronDown')}</span>
<span class="mr-loaded-models-title">Image Metadata</span>
</button>
${this.getMetadataTooltipBadge('Drop or paste (Ctrl+V) an image, Civitai link, or workflow JSON here, or paste JSON into the box below.', 'Image metadata help')}
${this.getMetadataTooltipBadge('Drop or paste (Ctrl+V) an image, Civitai link, or workflow JSON/text file here, or paste JSON into the box below.', 'Image metadata help')}
</div>
</div>
</div>
<div class="mr-image-inspector-tools">
<input type="file" accept="image/png,image/jpeg,image/webp,application/json,.json" hidden data-image-inspector-input="file">
<input type="file" accept="image/png,image/jpeg,image/webp,application/json,.json,text/plain,.txt" hidden data-image-inspector-input="file">
<div class="mr-image-inspector-paste">
<textarea id="mr-image-inspector-json-text" class="mr-image-inspector-textarea" rows="3" aria-label="Paste workflow JSON" placeholder="Paste a ComfyUI workflow JSON or Civitai image link (e.g. https://civitai.red/images/145110824)..."></textarea>
<div class="mr-image-inspector-actions">
@@ -736,30 +790,19 @@ export const imageMetadataMethods = {
return true;
}
if (
(trimmed.startsWith('{') && trimmed.endsWith('}'))
|| (trimmed.startsWith('[') && trimmed.endsWith(']'))
) {
let parsed = null;
try {
parsed = JSON.parse(trimmed);
} catch {
// Ignore parse errors for non-JSON text.
}
if (parsed && typeof parsed === 'object') {
event.preventDefault?.();
event.stopPropagation?.();
const textarea = root.querySelector('.mr-image-inspector-textarea');
if (textarea) {
textarea.value = trimmed;
if (typeof Event === 'function') {
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
const parsedWorkflow = extractJsonFromText(trimmed);
if (parsedWorkflow && typeof parsedWorkflow === 'object') {
event.preventDefault?.();
event.stopPropagation?.();
const textarea = root.querySelector('.mr-image-inspector-textarea');
if (textarea) {
textarea.value = trimmed;
if (typeof Event === 'function') {
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
void this.inspectPastedWorkflow(trimmed);
return true;
}
void this.inspectPastedWorkflow(trimmed);
return true;
}
if (targetIsTextarea) {
@@ -779,7 +822,7 @@ export const imageMetadataMethods = {
if (isWorkflowFile(file)) {
return this.inspectWorkflowFile(file);
}
this.showNotification('Select an image or a workflow JSON file.', 'warning');
this.showNotification('Select an image, workflow JSON, or workflow text file.', 'warning');
return null;
},
@@ -807,15 +850,28 @@ export const imageMetadataMethods = {
return null;
}
try {
const text = await file.text();
const workflow = JSON.parse(text);
const raw = await readFileAsText(file);
const text = (raw || '').replace(/^\uFEFF/, '').trim();
const civitaiImageId = extractCivitaiImageId(text);
if (civitaiImageId) {
return this.inspectCivitaiImage(civitaiImageId, text);
}
const workflow = extractJsonFromText(text);
if (!workflow || typeof workflow !== 'object') {
throw new Error('Not valid JSON');
}
return this.runImageMetadataInspection({
source_type: 'workflow',
filename: file.name || 'Pasted workflow.json',
workflow,
});
} catch (error) {
this.showNotification(error.message || 'The workflow file is not valid JSON.', 'error');
const name = String(file?.name || '').toLowerCase();
const isTxt = name.endsWith('.txt') || file?.type === 'text/plain';
const msg = isTxt
? 'The text file does not contain valid workflow JSON.'
: (error.message && error.message !== 'Not valid JSON' ? error.message : 'The workflow file is not valid JSON.');
this.showNotification(msg, 'error');
return null;
}
},