Files
DemonGatanjieuandClaude Opus 5.5 df2c5dd6c3 Remote chip: the line to paste into the server's terminal, with Copy
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>
2026-10-16 18:29:51 +08:00

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);
}