Fix input sync bugs, improve LoRA UX, and harden edge cases
- Sync dict_input/dict_textarea/LoRA inputs on update:model-value (not just blur) to prevent silent data loss on quick saves - Split LoRA into name + strength fields, default strength to 1.0 - Stack LoRAs one per line instead of 3-card row - Collapse "Add New Sequence from Source File" into expansion - Add file selector to Pane A in dual-pane mode - Clear secondary pane state on directory change - Fix file radio resetting to first file on refresh - Handle bare-list JSON files and inf/nan edge cases Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
31
main.py
31
main.py
@@ -212,34 +212,35 @@ def index():
|
|||||||
with ui.row().classes('w-full gap-4'):
|
with ui.row().classes('w-full gap-4'):
|
||||||
with ui.column().classes('col'):
|
with ui.column().classes('col'):
|
||||||
ui.label('Pane A').classes('section-header q-mb-sm')
|
ui.label('Pane A').classes('section-header q-mb-sm')
|
||||||
|
_render_pane_file_selector(state)
|
||||||
render_batch_processor(state)
|
render_batch_processor(state)
|
||||||
with ui.column().classes('col pane-secondary'):
|
with ui.column().classes('col pane-secondary'):
|
||||||
ui.label('Pane B').classes('section-header q-mb-sm')
|
ui.label('Pane B').classes('section-header q-mb-sm')
|
||||||
_render_secondary_file_selector(s2)
|
_render_pane_file_selector(s2)
|
||||||
if s2.file_path and s2.file_path.exists():
|
if s2.file_path and s2.file_path.exists():
|
||||||
render_batch_processor(s2)
|
render_batch_processor(s2)
|
||||||
else:
|
else:
|
||||||
ui.label('Select a file above to begin.').classes(
|
ui.label('Select a file above to begin.').classes(
|
||||||
'text-caption q-pa-md')
|
'text-caption q-pa-md')
|
||||||
|
|
||||||
def _render_secondary_file_selector(s2: AppState):
|
def _render_pane_file_selector(pane_state: AppState):
|
||||||
json_files = sorted(s2.current_dir.glob('*.json'))
|
json_files = sorted(pane_state.current_dir.glob('*.json'))
|
||||||
json_files = [f for f in json_files if f.name not in (
|
json_files = [f for f in json_files if f.name not in (
|
||||||
'.editor_config.json', '.editor_snippets.json')]
|
'.editor_config.json', '.editor_snippets.json')]
|
||||||
file_names = [f.name for f in json_files]
|
file_names = [f.name for f in json_files]
|
||||||
|
|
||||||
current_val = s2.file_path.name if s2.file_path else None
|
current_val = pane_state.file_path.name if pane_state.file_path else None
|
||||||
|
|
||||||
def on_select(e):
|
def on_select(e):
|
||||||
if not e.value:
|
if not e.value:
|
||||||
return
|
return
|
||||||
fp = s2.current_dir / e.value
|
fp = pane_state.current_dir / e.value
|
||||||
data, mtime = load_json(fp)
|
data, mtime = load_json(fp)
|
||||||
s2.data_cache = data
|
pane_state.data_cache = data
|
||||||
s2.last_mtime = mtime
|
pane_state.last_mtime = mtime
|
||||||
s2.loaded_file = str(fp)
|
pane_state.loaded_file = str(fp)
|
||||||
s2.file_path = fp
|
pane_state.file_path = fp
|
||||||
s2.restored_indicator = None
|
pane_state.restored_indicator = None
|
||||||
_render_batch_tab_content.refresh()
|
_render_batch_tab_content.refresh()
|
||||||
|
|
||||||
ui.select(
|
ui.select(
|
||||||
@@ -301,6 +302,9 @@ def render_sidebar(state: AppState, dual_pane: dict):
|
|||||||
state.current_dir = p
|
state.current_dir = p
|
||||||
if dual_pane['state']:
|
if dual_pane['state']:
|
||||||
dual_pane['state'].current_dir = state.current_dir
|
dual_pane['state'].current_dir = state.current_dir
|
||||||
|
dual_pane['state'].file_path = None
|
||||||
|
dual_pane['state'].loaded_file = None
|
||||||
|
dual_pane['state'].data_cache = {}
|
||||||
state.config['last_dir'] = str(p)
|
state.config['last_dir'] = str(p)
|
||||||
save_config(state.current_dir, state.config['favorites'], state.config)
|
save_config(state.current_dir, state.config['favorites'], state.config)
|
||||||
state.loaded_file = None
|
state.loaded_file = None
|
||||||
@@ -344,6 +348,9 @@ def render_sidebar(state: AppState, dual_pane: dict):
|
|||||||
state.current_dir = Path(fav)
|
state.current_dir = Path(fav)
|
||||||
if dual_pane['state']:
|
if dual_pane['state']:
|
||||||
dual_pane['state'].current_dir = state.current_dir
|
dual_pane['state'].current_dir = state.current_dir
|
||||||
|
dual_pane['state'].file_path = None
|
||||||
|
dual_pane['state'].loaded_file = None
|
||||||
|
dual_pane['state'].data_cache = {}
|
||||||
state.config['last_dir'] = fav
|
state.config['last_dir'] = fav
|
||||||
save_config(state.current_dir, state.config['favorites'], state.config)
|
save_config(state.current_dir, state.config['favorites'], state.config)
|
||||||
state.loaded_file = None
|
state.loaded_file = None
|
||||||
@@ -443,9 +450,11 @@ def render_sidebar(state: AppState, dual_pane: dict):
|
|||||||
|
|
||||||
ui.label('Select File').classes('subsection-header q-mt-sm')
|
ui.label('Select File').classes('subsection-header q-mt-sm')
|
||||||
file_names = [f.name for f in json_files]
|
file_names = [f.name for f in json_files]
|
||||||
|
current = Path(state.loaded_file).name if state.loaded_file else None
|
||||||
|
selected = current if current in file_names else (file_names[0] if file_names else None)
|
||||||
ui.radio(
|
ui.radio(
|
||||||
file_names,
|
file_names,
|
||||||
value=file_names[0] if file_names else None,
|
value=selected,
|
||||||
on_change=lambda e: state._load_file(e.value) if e.value else None,
|
on_change=lambda e: state._load_file(e.value) if e.value else None,
|
||||||
).classes('w-full')
|
).classes('w-full')
|
||||||
|
|
||||||
|
|||||||
190
tab_batch_ng.py
190
tab_batch_ng.py
@@ -79,12 +79,17 @@ def find_insert_position(batch_list, parent_index, parent_seq_num):
|
|||||||
# --- Helper for repetitive dict-bound inputs ---
|
# --- Helper for repetitive dict-bound inputs ---
|
||||||
|
|
||||||
def dict_input(element_fn, label, seq, key, **kwargs):
|
def dict_input(element_fn, label, seq, key, **kwargs):
|
||||||
"""Create an input element bound to seq[key] via blur event."""
|
"""Create an input element bound to seq[key] via blur and model-value update."""
|
||||||
val = seq.get(key, '')
|
val = seq.get(key, '')
|
||||||
if isinstance(val, (int, float)):
|
if isinstance(val, (int, float)):
|
||||||
val = str(val) if element_fn != ui.number else val
|
val = str(val) if element_fn != ui.number else val
|
||||||
el = element_fn(label, value=val, **kwargs)
|
el = element_fn(label, value=val, **kwargs)
|
||||||
el.on('blur', lambda e, k=key: seq.__setitem__(k, e.sender.value))
|
|
||||||
|
def _sync(k=key):
|
||||||
|
seq[k] = el.value
|
||||||
|
|
||||||
|
el.on('blur', lambda _: _sync())
|
||||||
|
el.on('update:model-value', lambda _: _sync())
|
||||||
return el
|
return el
|
||||||
|
|
||||||
|
|
||||||
@@ -95,7 +100,7 @@ def dict_number(label, seq, key, default=0, **kwargs):
|
|||||||
# Try float first to handle "1.5" strings, then check if it's a clean int
|
# Try float first to handle "1.5" strings, then check if it's a clean int
|
||||||
fval = float(val)
|
fval = float(val)
|
||||||
val = int(fval) if fval == int(fval) else fval
|
val = int(fval) if fval == int(fval) else fval
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError, OverflowError):
|
||||||
val = default
|
val = default
|
||||||
el = ui.number(label, value=val, **kwargs)
|
el = ui.number(label, value=val, **kwargs)
|
||||||
|
|
||||||
@@ -103,8 +108,11 @@ def dict_number(label, seq, key, default=0, **kwargs):
|
|||||||
v = el.value
|
v = el.value
|
||||||
if v is None:
|
if v is None:
|
||||||
v = d
|
v = d
|
||||||
elif isinstance(v, float) and v == int(v):
|
elif isinstance(v, float):
|
||||||
v = int(v)
|
try:
|
||||||
|
v = int(v) if v == int(v) else v
|
||||||
|
except (OverflowError, ValueError):
|
||||||
|
v = d
|
||||||
seq[k] = v
|
seq[k] = v
|
||||||
|
|
||||||
el.on('blur', lambda _: _sync())
|
el.on('blur', lambda _: _sync())
|
||||||
@@ -113,9 +121,14 @@ def dict_number(label, seq, key, default=0, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
def dict_textarea(label, seq, key, **kwargs):
|
def dict_textarea(label, seq, key, **kwargs):
|
||||||
"""Textarea bound to seq[key] via blur."""
|
"""Textarea bound to seq[key] via blur and model-value update."""
|
||||||
el = ui.textarea(label, value=seq.get(key, ''), **kwargs)
|
el = ui.textarea(label, value=seq.get(key, ''), **kwargs)
|
||||||
el.on('blur', lambda e, k=key: seq.__setitem__(k, e.sender.value))
|
|
||||||
|
def _sync(k=key):
|
||||||
|
seq[k] = el.value
|
||||||
|
|
||||||
|
el.on('blur', lambda _: _sync())
|
||||||
|
el.on('update:model-value', lambda _: _sync())
|
||||||
return el
|
return el
|
||||||
|
|
||||||
|
|
||||||
@@ -126,7 +139,10 @@ def dict_textarea(label, seq, key, **kwargs):
|
|||||||
def render_batch_processor(state: AppState):
|
def render_batch_processor(state: AppState):
|
||||||
data = state.data_cache
|
data = state.data_cache
|
||||||
file_path = state.file_path
|
file_path = state.file_path
|
||||||
is_batch_file = KEY_BATCH_DATA in data or isinstance(data, list)
|
if isinstance(data, list):
|
||||||
|
data = {KEY_BATCH_DATA: data}
|
||||||
|
state.data_cache = data
|
||||||
|
is_batch_file = KEY_BATCH_DATA in data
|
||||||
|
|
||||||
if not is_batch_file:
|
if not is_batch_file:
|
||||||
ui.label('This is a Single file. To use Batch mode, create a copy.').classes(
|
ui.label('This is a Single file. To use Batch mode, create a copy.').classes(
|
||||||
@@ -158,67 +174,65 @@ def render_batch_processor(state: AppState):
|
|||||||
|
|
||||||
# Source file data for importing
|
# Source file data for importing
|
||||||
with ui.card().classes('w-full q-pa-md q-mb-lg'):
|
with ui.card().classes('w-full q-pa-md q-mb-lg'):
|
||||||
json_files = sorted(state.current_dir.glob('*.json'))
|
with ui.expansion('Add New Sequence from Source File', icon='playlist_add').classes('w-full'):
|
||||||
json_files = [f for f in json_files if f.name not in (
|
json_files = sorted(state.current_dir.glob('*.json'))
|
||||||
'.editor_config.json', '.editor_snippets.json')]
|
json_files = [f for f in json_files if f.name not in (
|
||||||
file_options = {f.name: f.name for f in json_files}
|
'.editor_config.json', '.editor_snippets.json')]
|
||||||
|
file_options = {f.name: f.name for f in json_files}
|
||||||
|
|
||||||
src_file_select = ui.select(
|
src_file_select = ui.select(
|
||||||
file_options,
|
file_options,
|
||||||
value=file_path.name,
|
value=file_path.name,
|
||||||
label='Source File:',
|
label='Source File:',
|
||||||
).classes('w-64')
|
).classes('w-64')
|
||||||
|
|
||||||
src_seq_select = ui.select([], label='Source Sequence:').classes('w-64')
|
src_seq_select = ui.select([], label='Source Sequence:').classes('w-64')
|
||||||
|
|
||||||
# Track loaded source data
|
# Track loaded source data
|
||||||
_src_cache = {'data': None, 'batch': [], 'name': None}
|
_src_cache = {'data': None, 'batch': [], 'name': None}
|
||||||
|
|
||||||
def _update_src():
|
def _update_src():
|
||||||
name = src_file_select.value
|
name = src_file_select.value
|
||||||
if name and name != _src_cache['name']:
|
if name and name != _src_cache['name']:
|
||||||
src_data, _ = load_json(state.current_dir / name)
|
src_data, _ = load_json(state.current_dir / name)
|
||||||
_src_cache['data'] = src_data
|
_src_cache['data'] = src_data
|
||||||
_src_cache['batch'] = src_data.get(KEY_BATCH_DATA, [])
|
_src_cache['batch'] = src_data.get(KEY_BATCH_DATA, [])
|
||||||
_src_cache['name'] = name
|
_src_cache['name'] = name
|
||||||
if _src_cache['batch']:
|
if _src_cache['batch']:
|
||||||
opts = {i: format_seq_label(s.get(KEY_SEQUENCE_NUMBER, i+1))
|
opts = {i: format_seq_label(s.get(KEY_SEQUENCE_NUMBER, i+1))
|
||||||
for i, s in enumerate(_src_cache['batch'])}
|
for i, s in enumerate(_src_cache['batch'])}
|
||||||
src_seq_select.set_options(opts, value=0)
|
src_seq_select.set_options(opts, value=0)
|
||||||
else:
|
else:
|
||||||
src_seq_select.set_options({})
|
src_seq_select.set_options({})
|
||||||
|
|
||||||
src_file_select.on_value_change(lambda _: _update_src())
|
src_file_select.on_value_change(lambda _: _update_src())
|
||||||
_update_src()
|
_update_src()
|
||||||
|
|
||||||
# --- Add New Sequence ---
|
def _add_sequence(new_item):
|
||||||
ui.label('Add New Sequence').classes('section-header q-mt-md')
|
new_item[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1
|
||||||
|
for k in [KEY_PROMPT_HISTORY, KEY_HISTORY_TREE, 'note', 'loras']:
|
||||||
|
new_item.pop(k, None)
|
||||||
|
batch_list.append(new_item)
|
||||||
|
data[KEY_BATCH_DATA] = batch_list
|
||||||
|
save_json(file_path, data)
|
||||||
|
render_sequence_list.refresh()
|
||||||
|
|
||||||
def _add_sequence(new_item):
|
with ui.row().classes('q-mt-sm'):
|
||||||
new_item[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1
|
def add_empty():
|
||||||
for k in [KEY_PROMPT_HISTORY, KEY_HISTORY_TREE, 'note', 'loras']:
|
_add_sequence(DEFAULTS.copy())
|
||||||
new_item.pop(k, None)
|
|
||||||
batch_list.append(new_item)
|
|
||||||
data[KEY_BATCH_DATA] = batch_list
|
|
||||||
save_json(file_path, data)
|
|
||||||
render_sequence_list.refresh()
|
|
||||||
|
|
||||||
with ui.row():
|
def add_from_source():
|
||||||
def add_empty():
|
item = copy.deepcopy(DEFAULTS)
|
||||||
_add_sequence(DEFAULTS.copy())
|
src_batch = _src_cache['batch']
|
||||||
|
sel_idx = src_seq_select.value
|
||||||
|
if src_batch and sel_idx is not None:
|
||||||
|
item.update(copy.deepcopy(src_batch[int(sel_idx)]))
|
||||||
|
elif _src_cache['data']:
|
||||||
|
item.update(copy.deepcopy(_src_cache['data']))
|
||||||
|
_add_sequence(item)
|
||||||
|
|
||||||
def add_from_source():
|
ui.button('Add Empty', icon='add', on_click=add_empty)
|
||||||
item = copy.deepcopy(DEFAULTS)
|
ui.button('From Source', icon='file_download', on_click=add_from_source)
|
||||||
src_batch = _src_cache['batch']
|
|
||||||
sel_idx = src_seq_select.value
|
|
||||||
if src_batch and sel_idx is not None:
|
|
||||||
item.update(copy.deepcopy(src_batch[int(sel_idx)]))
|
|
||||||
elif _src_cache['data']:
|
|
||||||
item.update(copy.deepcopy(_src_cache['data']))
|
|
||||||
_add_sequence(item)
|
|
||||||
|
|
||||||
ui.button('Add Empty', icon='add', on_click=add_empty)
|
|
||||||
ui.button('From Source', icon='file_download', on_click=add_from_source)
|
|
||||||
|
|
||||||
# --- Standard / LoRA / VACE key sets ---
|
# --- Standard / LoRA / VACE key sets ---
|
||||||
lora_keys = ['lora 1 high', 'lora 1 low', 'lora 2 high', 'lora 2 low',
|
lora_keys = ['lora 1 high', 'lora 1 low', 'lora 2 high', 'lora 2 low',
|
||||||
@@ -456,28 +470,46 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
|
|||||||
|
|
||||||
# --- LoRA Settings ---
|
# --- LoRA Settings ---
|
||||||
with ui.expansion('LoRA Settings', icon='style').classes('w-full'):
|
with ui.expansion('LoRA Settings', icon='style').classes('w-full'):
|
||||||
with ui.row().classes('w-full q-gutter-md'):
|
for lora_idx in range(1, 4):
|
||||||
for lora_idx in range(1, 4):
|
for tier, tier_label in [('high', 'High'), ('low', 'Low')]:
|
||||||
with ui.card().classes('col q-pa-sm surface-3'):
|
k = f'lora {lora_idx} {tier}'
|
||||||
ui.label(f'LoRA {lora_idx}').classes('text-subtitle2')
|
raw = str(seq.get(k, ''))
|
||||||
for tier, tier_label in [('high', 'High'), ('low', 'Low')]:
|
inner = raw.replace('<lora:', '').replace('>', '')
|
||||||
k = f'lora {lora_idx} {tier}'
|
# Split "name:strength" or just "name"
|
||||||
raw = str(seq.get(k, ''))
|
if ':' in inner:
|
||||||
disp = raw.replace('<lora:', '').replace('>', '')
|
parts = inner.rsplit(':', 1)
|
||||||
|
lora_name = parts[0]
|
||||||
|
try:
|
||||||
|
lora_strength = float(parts[1])
|
||||||
|
except ValueError:
|
||||||
|
lora_name = inner
|
||||||
|
lora_strength = 1.0
|
||||||
|
else:
|
||||||
|
lora_name = inner
|
||||||
|
lora_strength = 1.0
|
||||||
|
|
||||||
with ui.row().classes('w-full items-center'):
|
with ui.row().classes('w-full items-center q-gutter-sm'):
|
||||||
ui.label('<lora:').classes('text-caption font-mono')
|
ui.label(f'L{lora_idx} {tier_label}').classes(
|
||||||
lora_input = ui.input(
|
'text-caption').style('min-width: 55px')
|
||||||
f'L{lora_idx} {tier_label}',
|
name_input = ui.input(
|
||||||
value=disp,
|
'Name',
|
||||||
).classes('col').props('outlined dense')
|
value=lora_name,
|
||||||
ui.label('>').classes('text-caption font-mono')
|
).classes('col').props('outlined dense')
|
||||||
|
strength_input = ui.number(
|
||||||
|
'Str',
|
||||||
|
value=lora_strength,
|
||||||
|
min=0, max=10, step=0.1,
|
||||||
|
).props('outlined dense').style('max-width: 80px')
|
||||||
|
|
||||||
def on_lora_blur(e, key=k):
|
def _lora_sync(key=k, n_inp=name_input, s_inp=strength_input):
|
||||||
v = e.sender.value
|
name = n_inp.value or ''
|
||||||
seq[key] = f'<lora:{v}>' if v else ''
|
strength = s_inp.value if s_inp.value is not None else 1.0
|
||||||
|
seq[key] = f'<lora:{name}:{strength}>' if name else ''
|
||||||
|
|
||||||
lora_input.on('blur', on_lora_blur)
|
name_input.on('blur', lambda _, s=_lora_sync: s())
|
||||||
|
name_input.on('update:model-value', lambda _, s=_lora_sync: s())
|
||||||
|
strength_input.on('blur', lambda _, s=_lora_sync: s())
|
||||||
|
strength_input.on('update:model-value', lambda _, s=_lora_sync: s())
|
||||||
|
|
||||||
# --- Custom Parameters ---
|
# --- Custom Parameters ---
|
||||||
ui.label('Custom Parameters').classes('section-header q-mt-md')
|
ui.label('Custom Parameters').classes('section-header q-mt-md')
|
||||||
|
|||||||
Reference in New Issue
Block a user