Pages made at different times used their own greys (slate here, neutral there) and accents (sky, violet, indigo), and most ignored the Abyssal Scarlet theme. About 1,850 literal colours in web/styles and in CSS and inline styles written from JS now use the --amb-* tokens by what they are for: surfaces, text (new --amb-text-soft and --amb-text-dim), borders (new --amb-border-strong), the primary button, and one accent (new --amb-link, tinted with color-mix). Both themes define the new tokens. Literal colours stay where the colour is the meaning: status, prompt roles, voice emotions, canvas node colours, shadows and overlays; theme override rules are untouched. The share-code dialogs move from inline styles to .anomalous-share-* classes. Stylesheet versions are bumped. Checked: text contrast on every rail page, Prompt Studio, Prompt Notes, Model Sources, settings, model detail and editor, share codes, in both themes; structure check and JS tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
388 lines
15 KiB
JavaScript
388 lines
15 KiB
JavaScript
import { app } from "../../scripts/app.js";
|
|
import { createBrowserEntry } from "./modules/browser_entry.js";
|
|
import { createInterfaceSettings, getCurrentLanguage, setAbyssalScarletTheme, t } from "./modules/interface_settings.js";
|
|
|
|
export { setAbyssalScarletTheme };
|
|
|
|
const browserEntry = createBrowserEntry({ translate: t, getCurrentLanguage });
|
|
|
|
app.registerExtension({
|
|
name: "Anomalous.ModelBrowser",
|
|
settings: [...browserEntry.settings, ...createInterfaceSettings()],
|
|
actionBarButtons: browserEntry.actionBarButtons,
|
|
commands: browserEntry.commands,
|
|
keybindings: browserEntry.keybindings,
|
|
menuCommands: browserEntry.menuCommands,
|
|
setup: browserEntry.setup
|
|
});
|
|
|
|
// --- INJECTED WORKFLOW SHARE MODULE ---
|
|
// Workflow Share and Preview Modal for Anomalous_Model_Browser
|
|
|
|
const AMB_WorkflowShare = {
|
|
// ----------------------------------------------------------------------
|
|
// 1. Data Compression & Base64 Utils
|
|
// ----------------------------------------------------------------------
|
|
strToU8(str) {
|
|
return new TextEncoder().encode(str);
|
|
},
|
|
u8ToStr(u8) {
|
|
return new TextDecoder().decode(u8);
|
|
},
|
|
u8ToBase64(u8) {
|
|
let binary = '';
|
|
const len = u8.byteLength;
|
|
for (let i = 0; i < len; i++) {
|
|
binary += String.fromCharCode(u8[i]);
|
|
}
|
|
return window.btoa(binary);
|
|
},
|
|
base64ToU8(b64) {
|
|
const binary = window.atob(b64);
|
|
const len = binary.length;
|
|
const u8 = new Uint8Array(len);
|
|
for (let i = 0; i < len; i++) {
|
|
u8[i] = binary.charCodeAt(i);
|
|
}
|
|
return u8;
|
|
},
|
|
async compress(str) {
|
|
const stream = new Blob([this.strToU8(str)]).stream();
|
|
const compressedStream = stream.pipeThrough(new CompressionStream('deflate-raw'));
|
|
const response = new Response(compressedStream);
|
|
const blob = await response.blob();
|
|
const buffer = await blob.arrayBuffer();
|
|
return new Uint8Array(buffer);
|
|
},
|
|
async decompress(u8) {
|
|
const stream = new Blob([u8]).stream();
|
|
const decompressedStream = stream.pipeThrough(new DecompressionStream('deflate-raw'));
|
|
const response = new Response(decompressedStream);
|
|
const blob = await response.blob();
|
|
const buffer = await blob.arrayBuffer();
|
|
return this.u8ToStr(new Uint8Array(buffer));
|
|
},
|
|
|
|
// ----------------------------------------------------------------------
|
|
// 2. Skeleton Generation (Strip visual / default data)
|
|
// ----------------------------------------------------------------------
|
|
skeletonize(workflowJson) {
|
|
const wf = JSON.parse(JSON.stringify(workflowJson)); // deep copy
|
|
|
|
// ComfyUI workflow JSON format has "nodes" array
|
|
if (wf.nodes && Array.isArray(wf.nodes)) {
|
|
wf.nodes.forEach(node => {
|
|
// Delete layout coords and styles
|
|
delete node.pos;
|
|
delete node.size;
|
|
delete node.color;
|
|
delete node.bgcolor;
|
|
delete node.shape;
|
|
delete node.flags;
|
|
// Delete empty properties
|
|
if (node.properties && Object.keys(node.properties).length === 0) {
|
|
delete node.properties;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Remove view metadata
|
|
if (wf.extra) {
|
|
delete wf.extra.ds; // scale/offset
|
|
}
|
|
|
|
return wf;
|
|
},
|
|
|
|
// ----------------------------------------------------------------------
|
|
// 3. Auto-Layout Algorithm
|
|
// ----------------------------------------------------------------------
|
|
autoLayout(workflowJson) {
|
|
if (!workflowJson.nodes || !Array.isArray(workflowJson.nodes)) return workflowJson;
|
|
|
|
const nodes = workflowJson.nodes;
|
|
|
|
// 1. Build adjacency list and in-degrees
|
|
const adj = new Map();
|
|
const inDegree = new Map();
|
|
|
|
nodes.forEach(n => {
|
|
adj.set(n.id, []);
|
|
if (!inDegree.has(n.id)) inDegree.set(n.id, 0);
|
|
});
|
|
|
|
// Check links
|
|
if (workflowJson.links) {
|
|
workflowJson.links.forEach(link => {
|
|
if (!link) return;
|
|
const fromId = link[1];
|
|
const toId = link[3];
|
|
if (adj.has(fromId) && adj.has(toId)) {
|
|
adj.get(fromId).push(toId);
|
|
inDegree.set(toId, inDegree.get(toId) + 1);
|
|
}
|
|
});
|
|
}
|
|
|
|
// 2. Topological sort with depth levels
|
|
const depthMap = new Map(); // id -> depth
|
|
const queue = [];
|
|
|
|
nodes.forEach(n => {
|
|
if (inDegree.get(n.id) === 0) {
|
|
queue.push(n.id);
|
|
depthMap.set(n.id, 0);
|
|
}
|
|
});
|
|
|
|
while (queue.length > 0) {
|
|
const curr = queue.shift();
|
|
const currDepth = depthMap.get(curr);
|
|
|
|
const neighbors = adj.get(curr);
|
|
if (neighbors) {
|
|
neighbors.forEach(nxt => {
|
|
// Reduce in-degree
|
|
const ind = inDegree.get(nxt) - 1;
|
|
inDegree.set(nxt, ind);
|
|
|
|
// Update depth to be max(existing depth, currDepth + 1)
|
|
const existingDepth = depthMap.get(nxt) || 0;
|
|
depthMap.set(nxt, Math.max(existingDepth, currDepth + 1));
|
|
|
|
if (ind === 0) {
|
|
queue.push(nxt);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Handle cycles (nodes not reached)
|
|
nodes.forEach(n => {
|
|
if (!depthMap.has(n.id)) {
|
|
depthMap.set(n.id, 0);
|
|
}
|
|
});
|
|
|
|
// 3. Assign X, Y coordinates
|
|
const nodesByDepth = {};
|
|
nodes.forEach(n => {
|
|
const d = depthMap.get(n.id);
|
|
if (!nodesByDepth[d]) nodesByDepth[d] = [];
|
|
nodesByDepth[d].push(n);
|
|
});
|
|
|
|
// Spacing constants
|
|
const X_SPACING = 400;
|
|
const Y_SPACING = 300;
|
|
|
|
Object.keys(nodesByDepth).forEach(d => {
|
|
const levelNodes = nodesByDepth[d];
|
|
const depth = parseInt(d);
|
|
levelNodes.forEach((n, idx) => {
|
|
// Approximate size
|
|
n.pos = [
|
|
depth * X_SPACING,
|
|
idx * Y_SPACING
|
|
];
|
|
});
|
|
});
|
|
|
|
return workflowJson;
|
|
},
|
|
|
|
// ----------------------------------------------------------------------
|
|
// 4. Encode / Decode
|
|
// ----------------------------------------------------------------------
|
|
async encodeShareCode(workflowJson, isSkeleton) {
|
|
let targetJson = workflowJson;
|
|
if (isSkeleton) {
|
|
targetJson = this.skeletonize(workflowJson);
|
|
}
|
|
|
|
const jsonStr = JSON.stringify(targetJson);
|
|
const compressedU8 = await this.compress(jsonStr);
|
|
const base64Str = this.u8ToBase64(compressedU8);
|
|
|
|
const prefix = isSkeleton ? 'AMB1-' : 'AMB0-';
|
|
return prefix + base64Str;
|
|
},
|
|
|
|
async decodeShareCode(shareCode) {
|
|
if (!shareCode.startsWith('AMB0-') && !shareCode.startsWith('AMB1-')) {
|
|
throw new Error('Invalid Share Code Format.');
|
|
}
|
|
|
|
const isSkeleton = shareCode.startsWith('AMB1-');
|
|
const base64Str = shareCode.substring(5);
|
|
|
|
const compressedU8 = this.base64ToU8(base64Str);
|
|
const jsonStr = await this.decompress(compressedU8);
|
|
|
|
let workflowJson = JSON.parse(jsonStr);
|
|
|
|
if (isSkeleton) {
|
|
workflowJson = this.autoLayout(workflowJson);
|
|
}
|
|
|
|
return workflowJson;
|
|
},
|
|
|
|
// ----------------------------------------------------------------------
|
|
// 5. UI Modals
|
|
// ----------------------------------------------------------------------
|
|
showToast(message, color) {
|
|
const toast = document.createElement('div');
|
|
toast.textContent = message;
|
|
toast.style.cssText = `
|
|
position: fixed; bottom: 30px; right: 30px; background: var(--amb-bg-card-hover); color: ${color || '#fff'};
|
|
padding: 12px 20px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.5);
|
|
font-family: Arial, sans-serif; font-size: 14px; z-index: 9999999;
|
|
opacity: 0; transition: opacity 0.3s ease; border-left: 4px solid ${color || '#fff'};
|
|
`;
|
|
document.body.appendChild(toast);
|
|
setTimeout(() => toast.style.opacity = '1', 10);
|
|
setTimeout(() => {
|
|
toast.style.opacity = '0';
|
|
setTimeout(() => toast.remove(), 300);
|
|
}, 3000);
|
|
},
|
|
|
|
/** A dimmed overlay with one dialog box; looks come from .anomalous-share-* in the styles. */
|
|
createShareDialog(id, titleText, variant = '') {
|
|
const overlay = document.createElement('div');
|
|
if (id) overlay.id = id;
|
|
overlay.className = 'anomalous-share-overlay';
|
|
const content = document.createElement('div');
|
|
content.className = `anomalous-share-dialog${variant ? ` ${variant}` : ''}`;
|
|
const title = document.createElement('h2');
|
|
title.className = 'anomalous-share-title';
|
|
title.textContent = titleText;
|
|
content.appendChild(title);
|
|
overlay.appendChild(content);
|
|
return { overlay, content };
|
|
},
|
|
|
|
shareButton(label, variant = '') {
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = `anomalous-share-btn${variant ? ` ${variant}` : ''}`;
|
|
button.textContent = label;
|
|
return button;
|
|
},
|
|
|
|
showExportModal() {
|
|
const { overlay, content } = this.createShareDialog('amb-export-modal', t('mainExportTitle'));
|
|
|
|
const typeSelectContainer = document.createElement('div');
|
|
typeSelectContainer.className = 'anomalous-share-options';
|
|
typeSelectContainer.innerHTML = `
|
|
<label>
|
|
<input type="radio" name="amb-share-type" value="skeleton" checked />
|
|
${t('mainSkeletonOption')}
|
|
</label>
|
|
<label>
|
|
<input type="radio" name="amb-share-type" value="full" />
|
|
${t('mainFullOption')}
|
|
</label>
|
|
`;
|
|
|
|
const textArea = document.createElement('textarea');
|
|
textArea.className = 'anomalous-share-code';
|
|
textArea.readOnly = true;
|
|
|
|
const btnGroup = document.createElement('div');
|
|
btnGroup.className = 'anomalous-share-actions';
|
|
const generateBtn = this.shareButton(t('mainGenerate'), 'is-primary');
|
|
const copyBtn = this.shareButton(t('mainCopyClipboard'));
|
|
copyBtn.hidden = true;
|
|
const closeBtn = this.shareButton(t('mainClose'), 'is-quiet');
|
|
|
|
closeBtn.onclick = () => overlay.remove();
|
|
|
|
generateBtn.onclick = async () => {
|
|
const isSkeleton = document.querySelector('input[name="amb-share-type"]:checked').value === 'skeleton';
|
|
|
|
// Get current workflow from app graph
|
|
const p = await app.graphToPrompt();
|
|
const workflowJson = p.workflow;
|
|
|
|
try {
|
|
const code = await AMB_WorkflowShare.encodeShareCode(workflowJson, isSkeleton);
|
|
textArea.value = code;
|
|
copyBtn.hidden = false;
|
|
} catch (err) {
|
|
textArea.value = 'Error generating code: ' + err.message;
|
|
}
|
|
};
|
|
|
|
copyBtn.onclick = () => {
|
|
textArea.select();
|
|
document.execCommand('copy');
|
|
AMB_WorkflowShare.showToast(t('mainCopied'), '#5cb85c');
|
|
};
|
|
|
|
btnGroup.append(generateBtn, copyBtn, closeBtn);
|
|
content.append(typeSelectContainer, textArea, btnGroup);
|
|
document.body.appendChild(overlay);
|
|
},
|
|
|
|
showImportModal() {
|
|
const { overlay, content } = this.createShareDialog('amb-import-modal', t('mainImportTitle'), 'is-wide');
|
|
|
|
const inputArea = document.createElement('textarea');
|
|
inputArea.className = 'anomalous-share-code is-input';
|
|
inputArea.placeholder = t('mainSharePlaceholder');
|
|
|
|
const btnGroup = document.createElement('div');
|
|
btnGroup.className = 'anomalous-share-actions';
|
|
const loadBtn = this.shareButton(t('mainImportLoad'), 'is-primary');
|
|
const closeBtn = this.shareButton(t('mainCancel'), 'is-quiet');
|
|
|
|
closeBtn.onclick = () => overlay.remove();
|
|
|
|
loadBtn.onclick = async () => {
|
|
const code = inputArea.value.trim();
|
|
if (!code) {
|
|
AMB_WorkflowShare.showToast(t('mainShareEmpty'), '#ff6b6b');
|
|
return;
|
|
}
|
|
try {
|
|
const pendingWorkflow = await AMB_WorkflowShare.decodeShareCode(code);
|
|
app.loadGraphData(pendingWorkflow);
|
|
overlay.remove();
|
|
|
|
const nodesCount = pendingWorkflow.nodes ? pendingWorkflow.nodes.length : 0;
|
|
AMB_WorkflowShare.showToast(t('mainImportedNodes', { count: nodesCount }), '#5cb85c');
|
|
|
|
// Auto close the main browser panel
|
|
const mainCloseBtn = document.getElementById('anomalous-close');
|
|
if (mainCloseBtn) mainCloseBtn.click();
|
|
} catch (err) {
|
|
AMB_WorkflowShare.showToast(t('mainDecodeFailed') + err.message, '#ff6b6b');
|
|
}
|
|
};
|
|
|
|
btnGroup.append(loadBtn, closeBtn);
|
|
content.append(inputArea, btnGroup);
|
|
document.body.appendChild(overlay);
|
|
},
|
|
showUnifiedModal() {
|
|
const { overlay, content } = this.createShareDialog('', t('mainUnifiedTitle'), 'is-menu');
|
|
|
|
const exportBtn = this.shareButton(t('mainExportWorkflow'), 'is-primary is-large');
|
|
exportBtn.onclick = () => { overlay.remove(); this.showExportModal(); };
|
|
|
|
const importBtn = this.shareButton(t('mainImportWorkflow'), 'is-large');
|
|
importBtn.onclick = () => { overlay.remove(); this.showImportModal(); };
|
|
|
|
const closeBtn = this.shareButton(t('mainClose'), 'is-quiet');
|
|
closeBtn.onclick = () => overlay.remove();
|
|
|
|
content.append(exportBtn, importBtn, closeBtn);
|
|
document.body.appendChild(overlay);
|
|
}
|
|
};
|
|
|
|
window.AMB_WorkflowShare = AMB_WorkflowShare;
|