Support .txt workflow inspection and unify prompt comparison text field colors
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"}})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user