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:
co-authored by
Claude Opus 5.5
parent
a7c39df7a5
commit
99576e60bd
@@ -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
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user