Cloud users had to find the plugin folder and make allow_remote_changes themselves. GET /anomalous/access now also answers another device with "command": the line that makes the file (touch, or echo off > on Windows, which works in cmd and PowerShell), or removes it when it is there, with this server's real path, quoted. The chip's dialog shows it with a Copy button; when the browser refuses the clipboard the line is selected for Ctrl+C. The line only runs where someone pastes it, on the server. anomalousAlert takes options.copy; copyText moved from ui_share.js to ui_dialog.js so both use it. Checked behind the stand-in gateway: without the file the dialog offers the line that makes it, with the file the line that removes it; both Windows lines tried in cmd and PowerShell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
235 lines
10 KiB
JavaScript
235 lines
10 KiB
JavaScript
/**
|
|
* Share and import (the Workflows page's ⇅): the canvas workflow as a short share code
|
|
* (share_code.js) in two spellings, Chinese characters (shortest) and letters (passes any
|
|
* filter); and one place to bring anything in: a pasted share code (AMB2/AMB1/AMB0) or
|
|
* workflow JSON, a dropped or chosen workflow file or image (ComfyUI's own loader), or a .zip:
|
|
* a recipe package (ui_recipe_package.js) or a backup (ui_backup.js).
|
|
*/
|
|
|
|
import { app } from '../../../scripts/app.js';
|
|
import { translate as t } from './locales.js';
|
|
import { decodeShareCode, encodeShareCode } from './share_code.js';
|
|
import { packOf } from './combo_slots.js';
|
|
import { isModelFilename } from './model_source_links.js';
|
|
import { importBackupFile } from './ui_backup.js';
|
|
import { copyText } from './ui_dialog.js';
|
|
import { importRecipePackageFile } from './ui_recipe_package.js';
|
|
import { showWorkbenchToast } from './ui_prompt_toast.js';
|
|
|
|
const templates = new Map();
|
|
|
|
/** A fresh node of `type`: its ports and size (null when the type is not installed). */
|
|
function templateOf(type) {
|
|
if (templates.has(type)) return templates.get(type);
|
|
let template = null;
|
|
try {
|
|
const node = globalThis.LiteGraph?.createNode(type);
|
|
if (node) {
|
|
const data = node.serialize();
|
|
template = { inputs: data.inputs || [], outputs: data.outputs || [], size: data.size || null };
|
|
}
|
|
} catch (error) {
|
|
console.warn(`[AMB] Share code: could not make a ${type} to compare with.`, error);
|
|
}
|
|
templates.set(type, template);
|
|
return template;
|
|
}
|
|
|
|
const HELPERS = { templateOf, isCore: type => Boolean(templateOf(type)) && !packOf(type) };
|
|
|
|
function el(tag, className, text) {
|
|
const node = document.createElement(tag);
|
|
if (className) node.className = className;
|
|
if (text !== undefined) node.textContent = text;
|
|
return node;
|
|
}
|
|
|
|
function button(label, variant, onClick) {
|
|
const node = el('button', `anomalous-share-btn${variant ? ` ${variant}` : ''}`, label);
|
|
node.type = 'button';
|
|
node.onclick = onClick;
|
|
return node;
|
|
}
|
|
|
|
/** What a workflow tells others about its models: how many carry a fingerprint, how many links. */
|
|
function provenance(workflow) {
|
|
const models = new Set();
|
|
for (const node of workflow.nodes || []) {
|
|
for (const value of Array.isArray(node.widgets_values) ? node.widgets_values : []) {
|
|
if (isModelFilename(value)) models.add(`${node.id}_${value}`);
|
|
}
|
|
}
|
|
const hashes = workflow.extra?.anomalous_hashes || {};
|
|
const hashed = [...models].filter(key => hashes[key]?.hash).length;
|
|
return { models: models.size, hashed, links: Object.keys(workflow.extra?.anomalous_model_sources || {}).length };
|
|
}
|
|
|
|
function provenanceNote(summary) {
|
|
if (!summary.models) return '';
|
|
if (localStorage.getItem('anomalous_inject_hash') === 'false') return t('mainShareProvenanceOff');
|
|
const missing = summary.models - summary.hashed;
|
|
return [t('mainShareFingerprints', { hashed: summary.hashed, total: summary.models }),
|
|
missing ? t('mainShareNoFingerprint', { count: missing }) : ''].filter(Boolean).join(' ');
|
|
}
|
|
|
|
/** The share part: the code is made when the dialog opens, again when links are left out. */
|
|
function exportSection() {
|
|
const section = el('section', 'anomalous-share-section');
|
|
section.append(el('h3', 'anomalous-share-heading', t('shareExportHeading')), el('p', 'anomalous-share-note', t('shareExportHelp')));
|
|
const code = el('textarea', 'anomalous-share-code');
|
|
code.readOnly = true;
|
|
const stats = el('p', 'anomalous-share-stats');
|
|
const note = el('p', 'anomalous-share-note');
|
|
const linksLabel = el('label', 'anomalous-share-check');
|
|
const linksBox = el('input');
|
|
linksBox.type = 'checkbox';
|
|
linksBox.checked = true;
|
|
const linksText = el('span');
|
|
linksLabel.append(linksBox, linksText);
|
|
const actions = el('div', 'anomalous-share-actions');
|
|
let current = null;
|
|
const copy = (kind) => async () => {
|
|
if (!current) return;
|
|
const text = current[kind];
|
|
if (await copyText(text)) showWorkbenchToast(t('shareCopied', { kind: t(kind === 'hanzi' ? 'shareKindHanzi' : 'shareKindLetters'), count: text.length }));
|
|
};
|
|
actions.append(button(t('shareCopyHanzi'), 'is-primary', copy('hanzi')), button(t('shareCopyLetters'), '', copy('letters')));
|
|
section.append(code, stats, linksLabel, note, actions, el('p', 'anomalous-share-note', t('shareCodeHelp')));
|
|
|
|
const make = async () => {
|
|
const workflow = (await app.graphToPrompt()).workflow;
|
|
if (!workflow?.nodes?.length) {
|
|
code.value = '';
|
|
stats.textContent = t('shareEmptyCanvas');
|
|
actions.hidden = true;
|
|
linksLabel.hidden = true;
|
|
return;
|
|
}
|
|
const summary = provenance(workflow);
|
|
linksText.textContent = ` ${t('mainShareLinks', { count: summary.links })}`;
|
|
linksLabel.hidden = !summary.links;
|
|
note.textContent = provenanceNote(summary);
|
|
note.hidden = !note.textContent;
|
|
if (!linksBox.checked) delete workflow.extra?.anomalous_model_sources;
|
|
current = await encodeShareCode(workflow, HELPERS);
|
|
code.value = current.hanzi;
|
|
stats.textContent = [t('shareStats', { hanzi: current.hanzi.length, letters: current.letters.length }),
|
|
current.whole ? t('shareWhole') : ''].filter(Boolean).join(' ');
|
|
actions.hidden = false;
|
|
};
|
|
linksBox.onchange = () => void make().catch(fail);
|
|
const fail = (error) => {
|
|
console.error('[AMB] Share code: making it failed.', error);
|
|
stats.textContent = t('shareImportFailed', { error: error.message });
|
|
};
|
|
void make().catch(fail);
|
|
return section;
|
|
}
|
|
|
|
/** Fits the loaded workflow into view (share codes carry no view). */
|
|
function fitView() {
|
|
try {
|
|
void app.extensionManager?.command?.execute?.('Comfy.Canvas.FitView');
|
|
} catch { /* the view stays */ }
|
|
}
|
|
|
|
/** The import part: paste, drop or choose; `done()` closes the dialog. */
|
|
function importSection(owner, done) {
|
|
const section = el('section', 'anomalous-share-section');
|
|
const input = el('textarea', 'anomalous-share-code is-input');
|
|
input.placeholder = t('shareImportPlaceholder');
|
|
const status = el('p', 'anomalous-share-note');
|
|
const opened = (count) => {
|
|
done();
|
|
document.getElementById('anomalous-close')?.click(); // the canvas is what the user wants to see now
|
|
showWorkbenchToast(t('shareImported', { count }));
|
|
};
|
|
const fail = (error) => {
|
|
status.textContent = t('shareImportFailed', { error: error.message });
|
|
status.classList.add('is-bad');
|
|
};
|
|
const fromText = async (raw) => {
|
|
const text = String(raw || '').trim();
|
|
if (!text) throw new Error(t('shareImportEmpty'));
|
|
if (text.startsWith('{')) {
|
|
await app.handleFile(new File([text], 'workflow.json', { type: 'application/json' }));
|
|
opened(JSON.parse(text).nodes?.length || 0);
|
|
return;
|
|
}
|
|
const workflow = await decodeShareCode(text, HELPERS);
|
|
await app.loadGraphData(workflow);
|
|
fitView();
|
|
opened(workflow.nodes?.length || 0);
|
|
};
|
|
const fromFile = async (file) => {
|
|
if (/\.zip$/i.test(file.name)) {
|
|
// A recipe package, else a backup (the backup's own dialog says when it is neither).
|
|
if (!/^AMB-backup-/i.test(file.name) && await importRecipePackageFile(owner, file)) {
|
|
done();
|
|
return;
|
|
}
|
|
done();
|
|
await importBackupFile(owner, file);
|
|
return;
|
|
}
|
|
await app.handleFile(file);
|
|
opened(app.graph?._nodes?.length || 0);
|
|
};
|
|
const run = (work) => {
|
|
status.classList.remove('is-bad');
|
|
status.textContent = '';
|
|
void work().catch(fail);
|
|
};
|
|
const chooser = el('input');
|
|
chooser.type = 'file';
|
|
chooser.accept = '.json,.png,.webp,.jpg,.jpeg,.flac,.mp4,.webm,.zip';
|
|
chooser.onchange = () => { if (chooser.files?.[0]) run(() => fromFile(chooser.files[0])); };
|
|
const actions = el('div', 'anomalous-share-actions');
|
|
actions.append(button(t('shareChooseFile'), '', () => chooser.click()),
|
|
button(t('shareImportButton'), 'is-primary', () => run(() => fromText(input.value))));
|
|
section.append(el('h3', 'anomalous-share-heading', t('shareImportHeading')), input, status, actions);
|
|
return { section, dropFile: file => run(() => fromFile(file)), dropText: (text) => { input.value = text; run(() => fromText(text)); } };
|
|
}
|
|
|
|
/** Opens the dialog. */
|
|
export function openShareDialog(owner) {
|
|
const overlay = el('div', 'anomalous-share-overlay');
|
|
const dialog = el('div', 'anomalous-share-dialog is-wide');
|
|
dialog.setAttribute('role', 'dialog');
|
|
const close = () => {
|
|
overlay.remove();
|
|
document.removeEventListener('keydown', onKey, true);
|
|
};
|
|
const onKey = (event) => {
|
|
if (event.key !== 'Escape') return;
|
|
event.stopPropagation();
|
|
close();
|
|
};
|
|
const incoming = importSection(owner, close);
|
|
const footer = el('div', 'anomalous-share-actions');
|
|
footer.append(button(t('mainClose'), 'is-quiet', close));
|
|
dialog.append(el('h2', 'anomalous-share-title', t('shareTitle')), exportSection(), incoming.section, footer);
|
|
// Anything dropped on the dialog is imported (and never reaches the canvas behind it).
|
|
overlay.addEventListener('dragover', (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
dialog.classList.add('is-drop');
|
|
});
|
|
overlay.addEventListener('dragleave', (event) => {
|
|
if (event.target === overlay) dialog.classList.remove('is-drop');
|
|
});
|
|
overlay.addEventListener('drop', (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
dialog.classList.remove('is-drop');
|
|
const file = event.dataTransfer?.files?.[0];
|
|
const text = event.dataTransfer?.getData('text/plain');
|
|
if (file) incoming.dropFile(file);
|
|
else if (text) incoming.dropText(text);
|
|
});
|
|
overlay.onclick = (event) => { if (event.target === overlay) close(); };
|
|
document.addEventListener('keydown', onKey, true);
|
|
overlay.append(dialog);
|
|
document.body.append(overlay);
|
|
}
|