Fix picker jumping to the top, combo saved per tag, unreadable long-text activity

- Current node's model picker redrew the whole grid to mark the chosen card, so
  choosing one further down scrolled back to the top. The cards are repainted in
  place (paintCard) instead.
- The classic combo editor saved when its bilingual tags were shown and again for
  each tag's translation as it arrived: a long prompt wrote the combo dozens of
  times in a second (34 note_save entries in one user's log). Rendering no longer
  saves and the translations share one save half a second after the last. Its
  prompt placeholder moves into locales.js (no inline language branch).
- Activity values are capped at 400 characters from the start, so an edit late in
  a long prompt logged identical before and after. A long before/after pair is now
  cut around its first difference (test included).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-10 16:36:15 +08:00
co-authored by Claude Opus 5.5
parent a7c39df7a5
commit 99576e60bd
6 changed files with 62 additions and 15 deletions
+3
View File
@@ -75,6 +75,9 @@
### Fixes
- **Ctrl+Z after a scan (扫描后的 Ctrl+Z)**: after any scan, the first Ctrl+Z used to seem to do nothing: the workflow's saved fingerprints had changed, which ComfyUI counted as a step. A change of fingerprints alone no longer takes a step.
- **Picking a model keeps your place (换模型时列表不再跳回顶部)**: in Current node's model picker, choosing a model further down redrew the whole list and scrolled back to the top (the model was chosen all the same). Only the chosen card's outline changes now.
- **A combo is saved once, not once per tag (搭配不再一下存几十次)**: opening a combo's bilingual tags saved it once for nothing, then once more for every tag's translation as it came back, so a long prompt wrote the file (and the activity log) dozens of times in a second. The translations are now saved together, half a second after the last one.
- **Activity shows where a long prompt changed (操作记录能看到长提示词改了哪里)**: values are kept to 400 characters, cut from the start, so an edit late in a long prompt showed the same text before and after. A long before/after pair is now cut around its first difference.
## v1.58.0 Beta (New Layout, Keep & Reuse, Prompt Studio, Model Check, Voices) — 2026-10-09
+28 -4
View File
@@ -24,6 +24,7 @@ from .path_utils import atomic_write_json
MAX_ENTRIES = 500
MAX_LOG_BYTES = 2 * 1024 * 1024 # long canvas entries could otherwise outgrow the JSON writer's limit
MAX_VALUE_CHARS = 400
DIFF_CONTEXT_CHARS = 60 # kept before the first difference of a long before/after pair
MAX_CHANGES = 60
# Write routes worth a line in the log → the action id the browser has words for. Scans are
@@ -99,11 +100,35 @@ def _trim(entries):
del entries[max(1, len(entries) * 9 // 10):]
def _text(value):
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
def _short(value):
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
text = _text(value)
return text if len(text) <= MAX_VALUE_CHARS else text[:MAX_VALUE_CHARS] + '…'
def _short_values(item, keys):
"""`keys` of `item` shortened. A long before/after pair is cut around its first difference
instead of at the start, so a change late in a long prompt still shows."""
out = {key: _short(item[key]) for key in keys if key in item and item[key] is not None}
if 'before' not in out or 'after' not in out:
return out
old, new = _text(item['before']), _text(item['after'])
if len(old) <= MAX_VALUE_CHARS and len(new) <= MAX_VALUE_CHARS:
return out
same = 0
while same < min(len(old), len(new)) and old[same] == new[same]:
same += 1
start = max(0, same - DIFF_CONTEXT_CHARS)
def cut(text):
return ('…' if start else '') + text[start:start + MAX_VALUE_CHARS] + ('…' if start + MAX_VALUE_CHARS < len(text) else '')
out['before'], out['after'] = cut(old), cut(new)
return out
def add_entry(source, action, target='', detail=None):
"""Append one entry (newest first) and save. Returns the entry."""
entry = {
@@ -180,7 +205,7 @@ def _clean_detail(detail):
return None
cleaned = {}
if isinstance(detail.get('fields'), list):
cleaned['fields'] = [{key: _short(item[key]) for key in ('field', 'before', 'after') if key in item}
cleaned['fields'] = [_short_values(item, ('field', 'before', 'after'))
for item in detail['fields'][:MAX_CHANGES] if isinstance(item, dict)]
if isinstance(detail.get('files'), list):
cleaned['files'] = [_short(name) for name in detail['files'][:MAX_CHANGES]]
@@ -206,8 +231,7 @@ def _clean_changes(changes):
for change in changes[:MAX_CHANGES]:
if not isinstance(change, dict):
continue
item = {key: _short(change[key]) for key in ('kind', 'node', 'type', 'widget', 'before', 'after')
if key in change and change[key] is not None}
item = _short_values(change, ('kind', 'node', 'type', 'widget', 'before', 'after'))
if item.get('kind') in ('changed', 'added', 'removed', 'opened'):
cleaned.append(item)
return cleaned
+10
View File
@@ -49,6 +49,16 @@ class ActivityLogTests(unittest.TestCase):
entry = activity_log.add_entry('file', 'note_save', 'x' * 1000)
self.assertLessEqual(len(entry['target']), activity_log.MAX_VALUE_CHARS + 1)
def test_a_change_late_in_a_long_text_still_shows(self):
before = ', '.join(f'tag{i}' for i in range(200)) # far longer than MAX_VALUE_CHARS
after = before.replace('tag180', 'tag180, extra')
item = activity_log._clean_changes([{'kind': 'changed', 'node': 'n', 'widget': 'text',
'before': before, 'after': after}])[0]
self.assertNotEqual(item['before'], item['after'])
self.assertIn('extra', item['after'])
self.assertTrue(item['before'].startswith('…'))
self.assertLessEqual(len(item['after']), activity_log.MAX_VALUE_CHARS + 2)
def test_failure_responses_are_not_recorded(self):
ok = web.json_response({'success': True})
self.assertFalse(activity_log._failed(ok))
+2
View File
@@ -880,6 +880,7 @@ export const i18n = {
pickerNoMatches: '没有符合条件的模型。',
pickerOperationFailed: '操作失败:',
notebookSelectMain: '请先选择一个主模型。',
notebookPromptPlaceholder: "在此输入提示词内容(英文词条用逗号分隔,支持自动双语切词翻译)...",
notebookUnlabeledBase: '未标注基础模型',
notebookUnetNeedsTextEncoder: 'UNet 模型不含文本编码器:发送到画布后,把你的 CLIP 加载器接到两个提示词节点上。LoRA 会用“仅模型”加载。',
mainPreflightTitle: '导入工作流 (预检海关)',
@@ -3011,6 +3012,7 @@ export const i18n = {
pickerNoMatches: 'No models match the current filters.',
pickerOperationFailed: 'Operation failed: ',
notebookSelectMain: 'Please select a Main Model first.',
notebookPromptPlaceholder: "Enter prompt here (comma-separated for tags, bilingual translation supported)...",
notebookUnlabeledBase: 'No base model listed',
notebookUnetNeedsTextEncoder: 'UNet models have no text encoder: after sending to the canvas, connect your CLIP loader to the two prompt nodes. LoRAs are loaded model-only.',
mainPreflightTitle: 'Pre-flight Import Workflow',
+11 -3
View File
@@ -297,6 +297,13 @@ export function _openGalleryReplacer(node, w, options = {}) {
});
};
// Marks the chosen card in place: redrawing the grid would empty it and scroll back to the top.
const paintCard = (card) => {
const path = card.dataset.path;
const isSelected = path === selectedPath;
card.style.borderColor = isSelected ? '#6ea8ff' : path === currentPath ? '#e9b949' : 'rgba(var(--amb-ink-rgb), 0.09)';
card.style.boxShadow = isSelected ? '0 0 0 2px rgba(88,151,255,0.22),0 14px 28px rgba(0,0,0,0.28)' : '0 8px 20px rgba(0,0,0,0.16)';
};
renderCards = () => {
const term = searchInput.value.trim().toLowerCase();
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
@@ -321,14 +328,15 @@ export function _openGalleryReplacer(node, w, options = {}) {
const end = Math.min(index + 40, paths.length);
for (; index < end; index += 1) {
const path = paths[index];
const isSelected = selectedPath === path;
const isCurrent = currentPath === path;
const info = modelInfo[path] || {};
const baseModel = info.metadata?.baseModel || '';
const card = document.createElement('div');
card.tabIndex = 0;
card.setAttribute('role', 'button');
card.style.cssText = `position:relative;background:linear-gradient(160deg,var(--amb-bg-card-hover),var(--amb-bg-card));border-radius:11px;overflow:hidden;cursor:pointer;display:flex;flex-direction:column;border:1px solid ${isSelected ? '#6ea8ff' : isCurrent ? '#e9b949' : 'rgba(var(--amb-ink-rgb), 0.09)'};box-shadow:${isSelected ? '0 0 0 2px rgba(88,151,255,0.22),0 14px 28px rgba(0,0,0,0.28)' : '0 8px 20px rgba(0,0,0,0.16)'};transition:transform 0.12s,box-shadow 0.12s;min-width:0;`;
card.dataset.path = path;
card.style.cssText = 'position:relative;background:linear-gradient(160deg,var(--amb-bg-card-hover),var(--amb-bg-card));border-radius:11px;overflow:hidden;cursor:pointer;display:flex;flex-direction:column;border:1px solid transparent;transition:transform 0.12s,box-shadow 0.12s;min-width:0;';
paintCard(card);
const previewBox = document.createElement('div');
previewBox.style.cssText = 'height:150px;background:radial-gradient(circle at 50% 15%,var(--amb-lt-card-hover,#2b3041),var(--amb-bg-page) 72%);display:flex;align-items:center;justify-content:center;font-size:30px;position:relative;overflow:hidden;';
const previewUrl = info.preview_url || previews[path];
@@ -384,7 +392,7 @@ export function _openGalleryReplacer(node, w, options = {}) {
const choose = () => {
selectedPath = path;
updateSelection();
renderCards();
grid.querySelectorAll('[data-path]').forEach(paintCard);
};
card.onclick = choose;
card.onkeydown = event => {
+8 -8
View File
@@ -289,9 +289,7 @@ function createPromptSection(ctx, data) {
const rawArea = document.createElement('textarea');
rawArea.className = 'anomalous-nb-textarea';
rawArea.value = data.promptEn || '';
rawArea.placeholder = window.anomalous_browser_lang === 'zh'
? '在此输入提示词内容(英文词条用逗号分隔,支持自动双语切词翻译)...'
: 'Enter prompt here (comma-separated for tags, bilingual translation supported)...';
rawArea.placeholder = t('notebookPromptPlaceholder');
const dualPane = document.createElement('div');
dualPane.className = 'anomalous-nb-dual-pane';
@@ -300,10 +298,13 @@ function createPromptSection(ctx, data) {
if (!data.translations) data.translations = {};
let visualDebounceTimer = null;
// One save after a burst (typing, or every tag's translation arriving), not one per change.
const saveSoon = () => {
clearTimeout(ctx.pTimeout);
ctx.pTimeout = setTimeout(() => ctx.saveCurrentNotebook(), 500);
};
const updateVisualTags = () => {
const txt = rawArea.value;
data.promptEn = txt;
ctx.saveCurrentNotebook();
if (!txt.trim()) {
dualPane.replaceChildren();
return;
@@ -388,7 +389,7 @@ function createPromptSection(ctx, data) {
if (d.translated) {
data.translations[tag] = d.translated;
txtR.innerText = d.translated;
ctx.saveCurrentNotebook();
saveSoon();
}
}).catch(() => { });
}
@@ -410,9 +411,8 @@ function createPromptSection(ctx, data) {
};
rawArea.oninput = () => {
clearTimeout(ctx.pTimeout);
data.promptEn = rawArea.value;
ctx.pTimeout = setTimeout(() => ctx.saveCurrentNotebook(), 500);
saveSoon();
if (dualPane.style.display !== 'none') {
clearTimeout(visualDebounceTimer);