Focus stars answer at once and save light

A star turns at once with a little bounce, the value fades in (one unmarked fades out), a failed save turns it back; quick presses queue instead of being refused. Focus values save alone through POST /anomalous/recipe_focus (this computer only, size-capped read, only focusParams written, no version), instead of a full recipe update that kept a version and re-read every model each time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-11 02:11:31 +08:00
co-authored by Claude Opus 5.5
parent 227063cce3
commit 9fdbaa847b
7 changed files with 178 additions and 53 deletions
+1 -1
View File
@@ -33,7 +33,7 @@
- A new set is named after the workflow with a number (阿罗娜K4.0 2); values saved without names show their real names instead of 控件 1, 控件 2. A set equal to the workflow's own values (saving a workflow keeps one) is no longer listed twice.
- **Compare pictures inside a workflow too**: every picture in 全部出图 and under a set has ⇄ — pick two, or one and the canvas or the default values. The pictures there now load as thumbnails.
- Cards keep the picture, the name, a line of steps, sampler, LoRAs and size, and the model. The models' state is a dot on the cover, with words only when some are missing; 功能模块 marks a partial workflow. **▶ Open** and **⋯** (edit, export, delete) are two small round buttons in the cover's corner, shown when the pointer is on a card, so pressing a card to look inside no longer opens it on the canvas by mistake.
- **Focus values (重点参数)**: mark what matters most in a workflow. Under 全部节点参数 every value has a ☆; marked values show at the top of 默认参数 and of every set as large tiles with the node they belong to, a note you can add (「0.6–0.8 最好」), and, on a set, the default value when it differs. Set rows list them first, and in ⇄ 对比 they lead the key values with their note. They belong to that workflow (the ☆ in the comparison window still stars a value for every workflow), are kept when the workflow is updated from the canvas, and travel with it in an exported workflow package (share codes carry only the canvas workflow).
- **Focus values (重点参数)**: mark what matters most in a workflow. Under 全部节点参数 every value has a ☆; marked values show at the top of 默认参数 and of every set as large tiles with the node they belong to, a note you can add (「0.6–0.8 最好」), and, on a set, the default value when it differs. Set rows list them first, and in ⇄ 对比 they lead the key values with their note. A star turns at once with a little bounce, the value fades in at the top, and marking or noting one saves only that (no new version of the workflow). They belong to that workflow (the ☆ in the comparison window still stars a value for every workflow), are kept when the workflow is updated from the canvas, and travel with it in an exported workflow package (share codes carry only the canvas workflow).
- **Tips where they help (参数方案的引导)**: a workflow without sets says what they are for, with **+ 从画布保存** right there; on a set, a line points at **⇄ 对比** until you have used it (or pressed 知道了).
- **Folds remember you (折叠记住你的选择)**: 用到的模型 and 全部节点参数 open as you last left them. 用到的模型 says on its title whether the models are all here or how many are missing, and opens by itself when some are.
- **Docked, the details fit (侧边栏模式不再挤)**: in a narrow panel the left column becomes one row of pills above the values (默认参数, your sets, new, 全部出图, 版本).
+1
View File
@@ -71,6 +71,7 @@ def setup_routes(app):
app.router.add_get('/anomalous/recipe_gallery_compare', recipes.api_get_recipe_gallery_compare)
app.router.add_post('/anomalous/save_recipe', recipes.api_save_recipe)
app.router.add_post('/anomalous/update_recipe', recipes.api_update_recipe)
app.router.add_post('/anomalous/recipe_focus', recipes.api_set_recipe_focus)
app.router.add_post('/anomalous/set_recipe_gallery_cover', recipes.api_set_recipe_gallery_cover)
app.router.add_post('/anomalous/delete_recipe', recipes.api_delete_recipe)
app.router.add_get('/anomalous/recipe_history', recipes.api_get_recipe_history)
+36 -1
View File
@@ -26,13 +26,14 @@ from .recipe_schema import (
_enrich_recipe as _schema_enrich_recipe, _normalise_recipe, _normalise_source_image,
_identity_for_reference as _schema_identity_for_reference,
_model_reference_key, _model_reference_specs,
_model_roots, _preserve_model_reference_fields,
_model_roots, _normalise_focus_params, _preserve_model_reference_fields,
_resolve_exact_model_reference, _updated_recipe, _workflow_identity_for_reference,
)
from .recipe_store import (
_archive_recipe, _delete_recipe_with_history, _history_dir, _list_recipe_history,
_list_recipes, _read_recipe, _write_recipe, get_recipes_dir,
)
from .mcp_server import _is_local
from .utils import require_filename, resolve_within
from .workflow_schema import (
_clean_volatile_params, _node_title, _node_type, _normalise_workflow_key,
@@ -359,6 +360,40 @@ async def api_update_recipe(request):
})
def _set_recipe_focus(recipes_dir, filename, entries):
"""Writes a recipe's focus values (重点参数) alone: no version kept, nothing else read again."""
focus = _normalise_focus_params(entries)
path = resolve_within(recipes_dir, filename)
if os.path.getsize(path) > MAX_RECIPE_BYTES:
raise ValueError("Recipe is too large")
recipe = _read_recipe(path)
if not isinstance(recipe, dict) or not isinstance(recipe.get("workflow"), dict):
raise ValueError("Invalid recipe")
params = recipe.get("params") if isinstance(recipe.get("params"), dict) else {}
recipe["params"] = {**params, "focusParams": focus}
_write_recipe(recipes_dir, filename, recipe)
return focus
async def api_set_recipe_focus(request):
"""Marks or notes a recipe's focus values (a star in its details). Only from this computer."""
if not _is_local(request):
return web.json_response({"status": "error", "message": "Only from this computer"}, status=403)
try:
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
if not filename.endswith(".json"):
raise ValueError("Invalid filename")
focus = await asyncio.to_thread(_set_recipe_focus, get_recipes_dir(), filename, payload.get("focusParams"))
except FileNotFoundError:
return web.json_response({"status": "error", "message": "File not found"}, status=404)
except (AttributeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid focus values"}, status=400)
except OSError:
return web.json_response({"status": "error", "message": "Could not update recipe"}, status=500)
return web.json_response({"status": "success", "focusParams": focus})
async def api_get_recipe_history(request):
try:
filename = require_filename(request.query.get("filename", ""))
+4 -1
View File
@@ -225,7 +225,10 @@ still match.
A workflow's focus values (重点参数) live in `params.focusParams`: at most 12
`{ node, type, widget, note }`, all strings (node ID, saved node type, widget
name, a note of at most 120 characters). `api/recipe_schema.py` refuses any other
shape and drops repeats. They are shown, compared and kept only while a node of
shape and drops repeats. A star or a note saves them alone through
`POST /anomalous/recipe_focus` (`api/recipes.py`, from this computer only): the
recipe file is read (up to its size limit), only `focusParams` changes, and no
version is kept, unlike a recipe update. They are shown, compared and kept only while a node of
that ID and type is still in the workflow (`recipe_focus.js focusEntries`); an
update from the canvas keeps the ones still true. Its parameter sets, the canvas
it opens and its pictures share the node IDs, so comparisons among them
+15
View File
@@ -123,6 +123,21 @@ class RecipeWorkflowValidationTests(unittest.TestCase):
with self.assertRaises(ValueError):
recipes._normalise_recipe(payload)
def test_focus_values_save_alone_without_a_version(self):
with tempfile.TemporaryDirectory() as temp_dir:
recipe = recipes._normalise_recipe(recipe_payload(valid_workflow()))
Path(temp_dir, "r.json").write_text(json.dumps(recipe), encoding="utf-8")
mark = {"node": "2", "type": "KSampler", "widget": "cfg", "note": "7-9"}
self.assertEqual(recipes._set_recipe_focus(temp_dir, "r.json", [mark]), [mark])
saved = json.loads(Path(temp_dir, "r.json").read_text(encoding="utf-8"))
self.assertEqual(saved["params"]["focusParams"], [mark])
self.assertEqual(saved["workflow"], recipe["workflow"])
self.assertFalse(Path(temp_dir, ".history").exists())
with self.assertRaises(ValueError):
recipes._set_recipe_focus(temp_dir, "r.json", [{"node": "2"}])
with self.assertRaises(FileNotFoundError):
recipes._set_recipe_focus(temp_dir, "missing.json", [])
def test_invalid_workflow_scope_is_rejected(self):
payload = recipe_payload(valid_workflow())
payload["workflow_scope"] = "selection"
+82 -42
View File
@@ -2,20 +2,34 @@
* A workflow's focus values (recipe_focus.js) in its details: the 重点参数 section at the top of
* a set (each value large with its node, its note, and for a saved set the workflow's own value
* when it differs), a line on how to mark one while there are none, and the ☆ on each node
* value under 全部节点参数. A change saves the recipe, then redraws the section, the stars and
* the sets' rows.
* value under 全部节点参数. A star turns at once and the save follows (a failed save turns it
* back); then the section, the other stars and the sets' rows are drawn again, the value just
* marked fading in.
*/
import { translate } from './locales.js';
import { anomalousAlert, anomalousPrompt } from './ui_dialog.js';
import { appendText, button } from './ui_recipe_detail_dom.js';
import { updateRecipeMetadata } from './ui_recipe_metadata.js';
import { redrawParameterSets, workflowParams } from './ui_param_set_keys.js';
import { shownValue } from './param_compare.js';
import { MAX_FOCUS, focusEntries, notedFocus, toggledFocus } from './recipe_focus.js';
const t = (key, params) => translate(key, params);
const starKey = (node, widget) => `${node}\n${widget}`;
let queue = Promise.resolve(); // changes save one after another, each from what the last one saved
/** Saves the focus values alone (/anomalous/recipe_focus: no new version), then keeps them on `recipe`. */
async function saveFocus(owner, recipe, focusParams) {
const response = await fetch('/anomalous/recipe_focus', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: owner.recipeDetailFilename, focusParams }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload.status !== 'success') throw new Error(payload.message || `HTTP ${response.status}`);
recipe.params = { ...(recipe.params || {}), focusParams: payload.focusParams };
}
/** The value of `entry`'s widget in a workflow read by workflowParams, as shown; null when it has none. */
function valueIn(params, entry) {
@@ -23,39 +37,54 @@ function valueIn(params, entry) {
return widget ? shownValue(widget.value) : null;
}
function showStar(star, on) {
star.classList.toggle('is-on', on);
star.textContent = on ? '★' : '☆';
star.title = t(on ? 'focusUnstar' : 'focusStar');
star.setAttribute('aria-pressed', String(on));
}
/** The star's little bounce when pressed (restarted on a quick second press). */
function pop(star) {
star.classList.remove('is-popping');
void star.offsetWidth;
star.classList.add('is-popping');
}
/** Marks every ☆ under `root` as the recipe's marks now say. */
function syncStars(root, recipe) {
const marked = new Set(focusEntries(recipe.params, recipe.workflow).map(entry => starKey(entry.node, entry.widget)));
for (const star of root.querySelectorAll('.anomalous-wfd-focus-star[data-focus]')) {
const on = marked.has(star.dataset.focus);
star.classList.toggle('is-on', on);
star.textContent = on ? '★' : '☆';
star.title = t(on ? 'focusUnstar' : 'focusStar');
}
for (const star of root.querySelectorAll('.anomalous-wfd-focus-star[data-focus]')) showStar(star, marked.has(star.dataset.focus));
}
/** Saves `params` (null: the list is full) on the recipe, then calls `after`. */
async function change(ctx, params, control, after) {
if (!params) {
await anomalousAlert(t('focusFull', { count: MAX_FOCUS }));
return;
}
control.disabled = true;
try {
await updateRecipeMetadata(ctx.owner, ctx.recipe, { params });
} catch (error) {
console.error('Could not save the focus values:', error);
await anomalousAlert(t('focusSaveError'));
return;
} finally {
control.disabled = false;
}
after();
redrawParameterSets();
/**
* Saves `next(params)` on the recipe (null: the list is full) after any change still saving;
* `after` once saved, `undo` when it was not (what the press already showed is taken back).
*/
function change(ctx, next, after, undo) {
queue = queue.then(async () => {
const params = next(ctx.recipe.params || {});
if (!params) {
undo();
await anomalousAlert(t('focusFull', { count: MAX_FOCUS }));
return;
}
try {
await saveFocus(ctx.owner, ctx.recipe, params.focusParams);
} catch (error) {
console.error('Could not save the focus values:', error);
undo();
await anomalousAlert(t('focusSaveError'));
return;
}
after();
redrawParameterSets();
});
return queue;
}
/** Draws the 重点参数 of `source` (the recipe or one of its sets) into `slot`. */
export function drawFocus(slot, ctx, source) {
/** Draws the 重点参数 of `source` (the recipe or one of its sets) into `slot`; `fresh`: the value just marked. */
export function drawFocus(slot, ctx, source, fresh = '') {
const { recipe } = ctx;
const redraw = () => {
drawFocus(slot, ctx, source);
@@ -67,7 +96,7 @@ export function drawFocus(slot, ctx, source) {
appendText(slot, 'p', t('focusHint'), 'anomalous-wfd-muted anomalous-wfd-focus-hint');
return;
}
const box = appendText(slot, 'section', '', 'anomalous-wfd-section anomalous-wfd-focus');
const box = appendText(slot, 'section', '', `anomalous-wfd-section anomalous-wfd-focus${fresh && entries.length === 1 ? ' is-new' : ''}`);
appendText(appendText(box, 'div', '', 'anomalous-wfd-section-head'), 'h3', t('focusTitle'));
const grid = appendText(box, 'div', '', 'anomalous-wfd-focus-grid');
const own = workflowParams(recipe.workflow);
@@ -76,13 +105,22 @@ export function drawFocus(slot, ctx, source) {
const value = valueIn(mine, entry);
const base = source === recipe ? value : valueIn(own, entry);
const differs = value !== null && base !== null && value !== base;
const tile = appendText(grid, 'div', '', `anomalous-wfd-focus-tile${differs ? ' is-differs' : ''}`);
const isNew = fresh === starKey(entry.node, entry.widget);
const tile = appendText(grid, 'div', '', `anomalous-wfd-focus-tile${differs ? ' is-differs' : ''}${isNew ? ' is-new' : ''}`);
const top = appendText(tile, 'div', '', 'anomalous-wfd-focus-top');
const nodeTitle = own.get(entry.node)?.title || entry.type;
appendText(top, 'span', entry.widget, 'anomalous-wfd-focus-name').title = `${nodeTitle} · ${entry.widget}`;
const unmark = button(top, '★', 'anomalous-wfd-focus-star is-on');
unmark.title = t('focusUnstar');
unmark.onclick = () => change(ctx, toggledFocus(recipe.params || {}, { id: entry.node, type: entry.type }, entry.widget), unmark, redraw);
const unmark = button(top, '', 'anomalous-wfd-focus-star');
showStar(unmark, true);
unmark.onclick = () => {
showStar(unmark, false);
pop(unmark);
tile.classList.add('is-leaving');
void change(ctx, params => toggledFocus(params, { id: entry.node, type: entry.type }, entry.widget), redraw, () => {
showStar(unmark, true);
tile.classList.remove('is-leaving');
});
};
appendText(tile, 'span', nodeTitle, 'anomalous-wfd-focus-node');
appendText(tile, 'strong', value ?? '—', 'anomalous-wfd-focus-value').title = value ?? '';
if (differs) appendText(tile, 'small', t('focusDefault', { value: base }), 'anomalous-wfd-focus-default');
@@ -91,7 +129,7 @@ export function drawFocus(slot, ctx, source) {
note.onclick = async () => {
const text = await anomalousPrompt(t('focusNotePrompt'), entry.note || '');
if (text === null) return;
await change(ctx, notedFocus(recipe.params || {}, entry, text), note, redraw);
await change(ctx, params => notedFocus(params, entry, text), redraw, () => {});
};
}
}
@@ -105,17 +143,19 @@ export function focusStars(ctx, slot, source) {
return (node, widget) => {
const star = document.createElement('button');
star.type = 'button';
star.className = 'anomalous-wfd-focus-star';
star.dataset.focus = starKey(node.id, widget);
star.className = `anomalous-wfd-focus-star${marked.has(star.dataset.focus) ? ' is-on' : ''}`;
star.textContent = marked.has(star.dataset.focus) ? '★' : '☆';
star.title = t(marked.has(star.dataset.focus) ? 'focusUnstar' : 'focusStar');
showStar(star, marked.has(star.dataset.focus));
star.onclick = (event) => {
event.stopPropagation();
void change(ctx, toggledFocus(ctx.recipe.params || {}, node, widget), star, () => {
drawFocus(slot, ctx, source);
const on = !star.classList.contains('is-on');
showStar(star, on);
pop(star);
void change(ctx, params => toggledFocus(params, node, widget), () => {
drawFocus(slot, ctx, source, on ? star.dataset.focus : '');
syncStars(star.closest('.anomalous-wfd-main') || slot.parentElement, ctx.recipe);
});
}, () => showStar(star, !on));
};
return star;
};
}
}
+39 -8
View File
@@ -427,24 +427,55 @@
.anomalous-wfd-focus-note.is-empty { color: var(--amb-text-dim); }
.anomalous-wfd-focus-star {
display: inline-flex;
flex: 0 0 auto;
width: 20px;
height: 20px;
margin-right: 4px;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
margin: -3px 4px -3px -4px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--amb-text-dim);
font-size: 13px;
font-size: 15px;
line-height: 1;
vertical-align: -1px;
vertical-align: middle;
cursor: pointer;
transition: color 0.15s ease, background-color 0.15s ease, transform 0.12s ease;
}
.anomalous-wfd-focus-top .anomalous-wfd-focus-star { margin-right: -4px; }
.anomalous-wfd-focus-star:hover { background: rgba(var(--amb-ink-rgb), 0.08); color: var(--amb-link); }
.anomalous-wfd-focus-star.is-on { color: #f5b301; }
.anomalous-wfd-focus-top .anomalous-wfd-focus-star { margin: -4px -6px -4px 0; }
.anomalous-wfd-focus-star:hover { background: rgba(var(--amb-ink-rgb), 0.08); color: #f5b301; transform: scale(1.12); }
.anomalous-wfd-focus-star:active { transform: scale(0.88); }
.anomalous-wfd-focus-star.is-on { color: #f5b301; text-shadow: 0 0 8px rgba(245, 179, 1, 0.45); }
.anomalous-wfd-focus-star.is-popping { animation: anomalous-focus-star-pop 0.32s cubic-bezier(0.3, 1.6, 0.5, 1); }
@keyframes anomalous-focus-star-pop {
0% { transform: scale(0.7); }
55% { transform: scale(1.35); }
100% { transform: scale(1); }
}
/* The value just marked fades in; one being unmarked fades while its save runs. */
.anomalous-wfd-focus-tile { transition: opacity 0.2s ease, transform 0.2s ease; }
.anomalous-wfd-focus-tile.is-new,
.anomalous-wfd-focus.is-new { animation: anomalous-focus-in 0.35s ease-out; }
.anomalous-wfd-focus-tile.is-leaving { opacity: 0.35; transform: scale(0.97); }
@keyframes anomalous-focus-in {
from { opacity: 0; transform: translateY(6px) scale(0.98); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.anomalous-wfd-focus-star,
.anomalous-wfd-focus-tile { transition: none; }
.anomalous-wfd-focus-star.is-popping,
.anomalous-wfd-focus-tile.is-new,
.anomalous-wfd-focus.is-new { animation: none; }
}
/* A tip under a set's name: what sets are for, or where ⇄ is. */
.anomalous-wfd-tip {