Compare: say which side is which

A and B each get their own colour, shown on the side cards, the column heads and every
text line. Prompts list the tags only A has and only B has (each once) instead of
removed/added; long texts take one line per side with the differing part marked; values
wrap whole instead of ending in an ellipsis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-10 19:37:01 +08:00
co-authored by Claude Opus 5.5
parent b920a711d1
commit ffe0cb4f40
9 changed files with 108 additions and 46 deletions
+2 -2
View File
@@ -392,8 +392,8 @@ covered by the `styles.css` manifest.
- `ui_gallery_card.js` builds one output image's gallery card (viewer, workbench, cover pick, drag, star, compare, delete);
`ui_param_compare.js` compares parameters: a card's ⇄ picks an image, a second image's ⇄ or
the canvas completes the pair (the image workbench's ⇄ takes the canvas), and its window says
whether both are the same node structure and lists each value that differs (prompts by tags
taken out and put in, seeds apart), writing ticked image values onto the canvas as one Ctrl+Z
whether both are the same node structure and lists each value that differs (prompts by the tags
only one side has, seeds apart), writing ticked image values onto the canvas as one Ctrl+Z
step with Undo; `param_compare.js` reads and compares the two sides without DOM.
`ui_keep_menu.js` is the star's keep menu (also the workbench's Keep): the whole workflow, the combo or the prompts,
each saying where it goes, opening what was kept already or removing it (to the Recycle Bin through the recipe,
+1 -1
View File
@@ -21,7 +21,7 @@
- A scanned model's card shows its Civitai version ("v2.0", "Illustrious", "fp8"…) as a small label above the name, so versions of one model that share a file name can be told apart.
### ⇄ Compare parameters (参数对比)
- Two pictures from the same workflow often differ in only a few values. Press ⇄ on one picture in the Gallery, then on another (or **Compare with the canvas**); the picture details have the same ⇄ for the canvas. The window says whether both use the same nodes and lists only what differs, node by node: prompts by the tags taken out and put in (marked positive or negative), seeds folded away, nodes only one side has listed apart. Two workflows numbered differently still compare the nodes each has one of, such as their only KSampler.
- Two pictures from the same workflow often differ in only a few values. Press ⇄ on one picture in the Gallery, then on another (or **Compare with the canvas**); the picture details have the same ⇄ for the canvas. The window says whether both use the same nodes and lists only what differs, node by node, A and B each in its own colour: prompts by the tags only A or only B has (marked positive or negative), long names one line per side with the differing part marked, seeds folded away, nodes only one side has listed apart. Two workflows numbered differently still compare the nodes each has one of, such as their only KSampler.
- Against the canvas, tick the values to take from the picture and put them on the canvas in one go (seeds stay); one Ctrl+Z or the toast's Undo takes them back.
### ⚙️ Settings in tabs (设置分组)
+3 -2
View File
@@ -129,8 +129,9 @@ the Recycle Bin through its own delete route (`delete_recipe`, `delete_notebook`
wiring and prompt-box role from `prompt_boxes.js`) and compares them (`compareParams`): nodes
pair by id and type, then leftover nodes by type when each side has exactly one of it; values
equal by their text form are counted, not listed; seeds (`isVolatileWidget`) are flagged.
`textDifference` shows a long text as tags removed and added, or as the changed span with
40 characters around it. `ui_param_compare.js` is the window: a side is an output image (its
`textDifference` shows a long text as the tags only one side has (each once), or as the
differing span with 40 characters around it; the window gives each side its own line and colour,
since neither side is older than the other. `ui_param_compare.js` is the window: a side is an output image (its
workflow from `inspect_image_material`, laid on a detached graph) or the live canvas. With the
canvas on one side, ticked image values are written through `applyNodeMaterialValues` (seeds,
missing choices and vanished nodes skipped and counted), all as one Ctrl+Z step, with Undo in
+3 -3
View File
@@ -55,9 +55,9 @@ assert.deepEqual(other.nodes.map(n => [n.aId, n.bId, n.id, n.rows[0].aText, n.ro
assert.deepEqual([other.onlyA.map(n => n.id), other.onlyB.map(n => n.id), other.compared], [['6', '7'], ['71', '72'], 1]);
assert.equal(compareParams(readParams(graph([node(1, 'A', {})])), readParams(graph([node(2, 'B', {})]))).compared, 0);
// Prompts as tags: what was taken out and put in.
assert.deepEqual(textDifference('1girl, red dress, smile', '1girl, smile, white dress, hat'),
{ tags: true, removed: ['red dress'], added: ['white dress', 'hat'] });
// Prompts as tags: the tags only one side has, each once.
assert.deepEqual(textDifference('1girl, red dress, smile, red dress', '1girl, smile, white dress, hat'),
{ tags: true, onlyA: ['red dress'], onlyB: ['white dress', 'hat'] });
// Other text: the changed part with a little around it.
const long = `${'a'.repeat(50)} cat ${'b'.repeat(50)}`;
assert.deepEqual(textDifference(long, long.replace('cat', 'dog')), {
+2
View File
@@ -592,6 +592,7 @@ export const i18n = {
compareState: "状态",
compareParameter: "参数",
compareMoreTags: "还有 {count} 个",
compareTagsOnly: "独有:",
compareModeOn: "启用",
compareModeMuted: "已静音",
compareModeBypassed: "已绕过",
@@ -2879,6 +2880,7 @@ export const i18n = {
compareState: "State",
compareParameter: "Parameter",
compareMoreTags: "{count} more",
compareTagsOnly: "only here:",
compareModeOn: "On",
compareModeMuted: "Muted",
compareModeBypassed: "Bypassed",
+6 -6
View File
@@ -112,9 +112,9 @@ export function compareParams(a, b) {
const tagsOf = text => String(text).split(/[,,\n]/).map(tag => tag.trim()).filter(Boolean);
/**
* How two texts differ, for showing: prompts written as tags by the tags taken out and put in
* ({ tags: true, removed, added }), anything else by the part that changed with a little of
* what surrounds it ({ tags: false, before, aPart, bPart, after }, cut ends marked with …).
* How two texts differ, for showing: prompts written as tags by the tags only one side has
* ({ tags: true, onlyA, onlyB }, each tag once), anything else by the part that differs with a
* little of what surrounds it ({ tags: false, before, aPart, bPart, after }, cut ends marked …).
*/
export function textDifference(aText, bText) {
const aTags = tagsOf(aText);
@@ -122,9 +122,9 @@ export function textDifference(aText, bText) {
if (aTags.length >= MIN_TAGS && bTags.length >= MIN_TAGS) {
const inA = new Set(aTags);
const inB = new Set(bTags);
const removed = aTags.filter(tag => !inB.has(tag));
const added = bTags.filter(tag => !inA.has(tag));
if (removed.length || added.length) return { tags: true, removed, added };
const onlyA = [...inA].filter(tag => !inB.has(tag));
const onlyB = [...inB].filter(tag => !inA.has(tag));
if (onlyA.length || onlyB.length) return { tags: true, onlyA, onlyB };
}
const a = String(aText);
const b = String(bText);
+35 -16
View File
@@ -15,7 +15,7 @@ import { showWorkbenchToast } from './ui_prompt_toast.js';
import { compareParams, isLongText, readParams, shownValue, textDifference } from './param_compare.js';
const MODE_KEYS = { 0: 'compareModeOn', 2: 'compareModeMuted', 4: 'compareModeBypassed' };
const SHOWN_TAGS = 12; // a prompt swapped whole lists every tag: the rest wait behind a button
const SHOWN_TAGS = 10; // per side: a prompt swapped whole lists every tag, the rest wait behind a button
const ROLE_KEYS = { positive: 'recipePromptRolePositive', negative: 'recipePromptRoleNegative', both: 'recipePromptRoleBoth' };
let openWindow = null;
@@ -62,31 +62,45 @@ function sideCard(side, letter) {
img.alt = '';
card.append(img);
}
const name = el('div', 'anomalous-compare-side-name', `${letter} · ${sideName(side)}`);
const name = el('div', 'anomalous-compare-side-name', sideName(side));
name.title = sideName(side);
card.append(name);
card.append(letterBadge(letter), name);
return card;
}
/** A long text's difference: tags taken out and put in, or the changed part in its place. */
/** A or B in its own colour, the same in the side cards, the column heads and the text lines. */
const letterBadge = letter => el('span', `anomalous-compare-letter is-${letter.toLowerCase()}`, letter);
/** One side's line of a long text: its letter, then its tags or its text with the differing part marked. */
function textLine(letter, parts) {
const line = el('div', `anomalous-compare-text-line is-${letter.toLowerCase()}`);
line.append(letterBadge(letter), ...parts);
return line;
}
/** A long text's difference, one line per side: the tags only that side has, or its differing part in place. */
function textCell(row) {
const cell = el('div', 'anomalous-compare-text');
const diff = textDifference(row.aText, row.bText);
if (diff.tags) {
const removed = diff.removed.map(tag => el('span', 'anomalous-compare-tag is-removed', `− ${tag}`));
const added = diff.added.map(tag => el('span', 'anomalous-compare-tag is-added', `+ ${tag}`));
// Both kinds show at first: half each, or more of one when the other has fewer.
const firstRemoved = Math.max(SHOWN_TAGS / 2, SHOWN_TAGS - added.length);
const shown = [...removed.slice(0, firstRemoved), ...added.slice(0, SHOWN_TAGS - Math.min(removed.length, firstRemoved))];
const rest = [...removed.slice(firstRemoved), ...added.slice(SHOWN_TAGS - Math.min(removed.length, firstRemoved))];
cell.append(...shown);
if (rest.length) {
const more = button('anomalous-compare-more', t('compareMoreTags', { count: rest.length }), () => more.replaceWith(...rest));
cell.append(more);
for (const [letter, tags] of [['A', diff.onlyA], ['B', diff.onlyB]]) {
if (!tags.length) continue;
const chips = tags.map(tag => el('span', 'anomalous-compare-tag', tag));
const parts = [el('span', 'anomalous-compare-line-label', t('compareTagsOnly')), ...chips.slice(0, SHOWN_TAGS)];
if (chips.length > SHOWN_TAGS) {
const more = button('anomalous-compare-more', t('compareMoreTags', { count: chips.length - SHOWN_TAGS }), () => more.replaceWith(...chips.slice(SHOWN_TAGS)));
parts.push(more);
}
cell.append(textLine(letter, parts));
}
return cell;
}
cell.append(document.createTextNode(diff.before), el('span', 'is-out', diff.aPart), el('span', 'is-in', diff.bPart), document.createTextNode(diff.after));
for (const [letter, part] of [['A', diff.aPart], ['B', diff.bPart]]) {
const text = el('span', 'anomalous-compare-text-body');
// A side with nothing where the other has more shows no mark: the other's line marks it.
text.append(document.createTextNode(diff.before), ...(part ? [el('mark', '', part)] : []), document.createTextNode(diff.after));
cell.append(textLine(letter, [text]));
}
return cell;
}
@@ -114,7 +128,12 @@ function rowLine(row, tick) {
function columnHeads() {
const line = el('div', 'anomalous-compare-row is-head');
line.append(el('span', '', t('compareParameter')), el('span', '', 'A'), el('span', '', 'B'));
const head = (letter) => {
const cell = el('span');
cell.append(letterBadge(letter));
return cell;
};
line.append(el('span', '', t('compareParameter')), head('A'), head('B'));
return line;
}
+1 -1
View File
@@ -25,4 +25,4 @@
@import url("./styles/24-backup.css?v=20261007-theme-2");
@import url("./styles/25-model-import.css?v=20261008-import");
@import url("./styles/26-model-tidy.css?v=20261008-tidy");
@import url("./styles/27-param-compare.css?v=20261010-compare-1");
@import url("./styles/27-param-compare.css?v=20261010-compare-2");
+55 -15
View File
@@ -99,6 +99,8 @@
}
.anomalous-compare-dialog {
--amb-compare-a: var(--amb-link);
--amb-compare-b: var(--amb-warn);
display: flex;
flex-direction: column;
gap: 12px;
@@ -280,36 +282,74 @@
.anomalous-compare-role.is-positive { color: var(--amb-ok-text); }
.anomalous-compare-role.is-negative { color: var(--amb-danger-text); }
/* Values wrap whole: a model's name often differs only at its end. */
.anomalous-compare-value {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--amb-text-main);
overflow-wrap: anywhere;
}
.anomalous-compare-value.is-a { color: var(--amb-text-muted); }
.anomalous-compare-value.is-b { color: var(--amb-text-main); font-weight: 600; }
.anomalous-compare-letter {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 5px;
font-size: 11px;
font-weight: 700;
line-height: 1;
}
.anomalous-compare-letter.is-a {
background: color-mix(in srgb, var(--amb-compare-a) 22%, transparent);
color: var(--amb-compare-a);
}
.anomalous-compare-letter.is-b {
background: color-mix(in srgb, var(--amb-compare-b) 22%, transparent);
color: var(--amb-compare-b);
}
.anomalous-compare-text {
grid-column: 2 / -1;
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.anomalous-compare-text-line {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: baseline;
gap: 4px 6px;
line-height: 1.6;
overflow-wrap: anywhere;
}
.anomalous-compare-text .is-out,
.anomalous-compare-tag.is-removed {
color: var(--amb-danger-text);
background: color-mix(in srgb, var(--amb-danger) 14%, transparent);
text-decoration: line-through;
.anomalous-compare-text-body { min-width: 0; flex: 1 1 0; }
.anomalous-compare-line-label {
color: var(--amb-text-muted);
font-size: 11.5px;
}
.anomalous-compare-text .is-in,
.anomalous-compare-tag.is-added {
color: var(--amb-ok-text);
background: color-mix(in srgb, var(--amb-ok) 14%, transparent);
.anomalous-compare-text-line mark {
border-radius: 3px;
padding: 0 2px;
color: inherit;
}
.anomalous-compare-text-line.is-a mark,
.anomalous-compare-text-line.is-a .anomalous-compare-tag {
background: color-mix(in srgb, var(--amb-compare-a) 20%, transparent);
}
.anomalous-compare-text-line.is-b mark,
.anomalous-compare-text-line.is-b .anomalous-compare-tag {
background: color-mix(in srgb, var(--amb-compare-b) 20%, transparent);
}
.anomalous-compare-more {