Files
odysseus/static/js/editor/ai-settings.js
T
39303f19cd fix(editor): stabilize image generation, layer editing and controls (#6992)
* fix(editor): stabilize image workflows and editing controls

* docs(editor): add desktop and mobile visual validation evidence

* fix(editor): synchronize quick edit model events and register imagePreferences in test coordinator

* fix(settings): enforce authoritative global image-gen policy and non-admin preference persistence

- Enforce authoritative administrator global image_gen_enabled disable in get_user_setting, do_generate_image, and image_editing_provider_available, preventing non-admin user preference overrides while preserving user-disabled opt-outs and can_generate_images privilege checks across direct, gallery, and editor paths.
- Filter non-admin preference persistence in saveImagePreferences to user preference keys only (image_model, image_edit_model, image_quality), preventing global policy toggles from leaking into user preferences.
- Return accurate 200 OK Response from saveImagePreferences after fallback persistence succeeds so settings UI displays 'Saved' instead of false 403 error, while retaining visibility of real errors.
- Load per-user preferences via readImagePreferences in initImageSettings so non-admin selections persist and display correctly after page reload.
- Clear per-user model preferences on successful admin save (POST /api/auth/settings) via PUT /api/prefs/{key} with value: null to prevent stale per-user preferences from shadowing updated global settings.
- Support clearing keys in PUT /api/prefs/{key} when value is null.
- Add comprehensive Python regression tests for global-disabled/user-enabled, global-enabled/user-disabled, and authorized generation, plus Node and browser regression tests for non-admin persistence, toggle isolation, reload, and admin clearing.

* fix(settings): invalidate shared appConfig cache near auth settings write

* fix(test): isolate test_tool_policy unit suite from external model endpoint state

* fix(test): isolate web_fetch failure regression from contextual follow-up heuristics

* fix(test): isolate test_tool_policy from cross-test approval gate state

Ensure _patch_loop_basics disables TOOL_APPROVAL_GATE_ENABLED so routing tests in test_tool_policy run in the intended opt-out posture even when prior tests populate DATA_DIR with skills or untrusted context. Remove unnecessary forced_tools and contextual web mock workarounds.

* fix(settings): isolate non-admin image toggle and surface preference cleanup errors

- Restrict image generation availability toggle to admin users in imageModels.js, disabling the switch for non-admins and reverting unauthorized changes with an explicit message.
- Separate non-admin image model preference saves from global toggle updates.
- Track preference cleanup results on admin save in imagePreferences.js, propagating cleanup failures instead of ignoring them.
- Require valid preference keys on 403 fallback so toggle-only attempts reject cleanly.
- Add /api/prefs route mock to test_image_model_settings_sync_both_directions_and_survive_reopening.
- Add regression coverage for admin cleanup failures and non-admin toggle rejection.

* fix(settings): reconcile admin toggle on partial cleanup failure and report errors

- In imagePreferences.js, distinguish pre-persistence failures from post-save cleanup failures using err.globalSaved and err.phase.

- In imagePreferences.js, notify listeners of global policy updates on partial success before propagating cleanup failure.

- In imageModels.js, retain the persisted target state on admin toggle when global persistence succeeds but cleanup fails.

- In imageModels.js, reconcile admin toggle with authoritative server state via readImagePreferences() after ambiguous or partial failures.

- In imageModels.js, report partial success accurately without suppressing preference cleanup error messages.

- Add regression coverage in test_settings_save_response_ok_js.py for cleanup HTTP 500, cleanup network rejection, successful cleanup, and failed global POST.

---------

Co-authored-by: pewdiepie-archdaemon <pewdiepie-archdaemon@users.noreply.github.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 02:56:46 +00:00

144 lines
6.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* AI settings dialog.
*
* Holds the image-edit model, the AI agent (vision) model, and the opt-in
* "Double-check AI edits" toggle. Opened from the Quick Edit gear button and
* from the editor setup checklist; keeps the Quick Edit box itself uncluttered.
*/
import { readImagePreferences, saveImagePreferences } from '../settings/imagePreferences.js';
import { updateFooterEditModel } from './ai-models.js';
export function createAiSettings({ container }) {
const el = document.createElement('div');
el.className = 'ge-ai-settings';
el.hidden = true;
el.setAttribute('role', 'dialog');
el.setAttribute('aria-modal', 'true');
el.setAttribute('aria-label', 'AI settings');
el.innerHTML = `
<div class="ge-ai-settings-card">
<div class="ge-ai-settings-head">
<h3>AI settings</h3>
<button type="button" class="ge-ai-settings-close" data-ai-settings-close aria-label="Close">×</button>
</div>
<div class="ge-ai-settings-body">
<label class="ge-ai-settings-label" for="ge-ai-model">Image generation model</label>
<select class="ge-ai-command-model" id="ge-ai-model" title="Image generation model" aria-label="Image generation model"></select>
<label class="ge-ai-settings-label" for="ge-ai-command-model">Image edit model</label>
<select class="ge-ai-command-model" id="ge-ai-command-model" title="Image edit model" aria-label="Image edit model"></select>
<label class="ge-ai-settings-label" for="ge-ai-command-agent-model">AI agent (vision) model</label>
<select class="ge-ai-command-model" id="ge-ai-command-agent-model" title="Quick Edit agent model" aria-label="Quick Edit agent model"></select>
<p class="ge-ai-settings-note">Recommended, not required — the vision agent plans edits and can double-check results.</p>
<p class="ge-ai-settings-note" id="ge-ai-settings-save-status" role="status"></p>
<label class="ge-ai-settings-label" for="ge-look-strength">Look strength</label>
<div class="ge-ai-settings-strength">
<input type="range" id="ge-look-strength" min="0.5" max="2" step="0.1" value="1.4" aria-label="Look grade strength" />
<span class="ge-ai-settings-strength-val" id="ge-look-strength-val">1.4×</span>
</div>
<p class="ge-ai-settings-note">How strongly named looks (Kodachrome, teal and orange, …) are graded.</p>
<label class="ge-ai-settings-opt" title="Ask the vision model to review the result and retry on a clear failure">
<input type="checkbox" id="ge-ai-command-selfcheck" /> Double-check AI edits
</label>
</div>
<div class="ge-ai-settings-foot">
<button type="button" class="ge-ai-settings-done" data-ai-settings-close>Done</button>
</div>
</div>`;
function open(focusId) {
el.hidden = false;
syncModels();
if (focusId) {
const target = document.getElementById(focusId);
if (target) {
try { target.focus({ preventScroll: true }); } catch (_) { target.focus(); }
}
}
}
function close() { el.hidden = true; }
el.addEventListener('click', (e) => {
if (e.target === el || e.target.closest('[data-ai-settings-close]')) {
e.preventDefault();
close();
}
});
const onKey = (e) => {
if (e.key !== 'Escape' || el.hidden) return;
e.preventDefault();
e.stopImmediatePropagation();
close();
};
window.addEventListener('keydown', onKey, true);
container.appendChild(el);
const status = el.querySelector('#ge-ai-settings-save-status');
const modelFields = [['ge-ai-model', 'image_model'], ['ge-ai-command-model', 'image_edit_model']];
function applyModels(settings) {
for (const [id, key] of modelFields) {
if (!(key in settings)) continue;
const select = el.querySelector(`#${id}`);
const model = settings[key] || '';
let option = [...select.options].find(option => {
const value = option.value;
return model ? value.slice(value.indexOf('::') + 2) === model : value === '';
});
if (!option && model && select.options.length) {
option = document.createElement('option');
option.value = `::${model}`;
option.textContent = `${model} (unavailable)`;
option.disabled = true;
select.append(option);
}
if (option) select.value = option.value;
}
updateFooterEditModel();
}
async function syncModels() {
try { applyModels(await readImagePreferences()); }
catch (error) { status.textContent = error.message; }
}
for (const [id, key] of modelFields) {
el.querySelector(`#${id}`).addEventListener('change', async (event) => {
const raw = event.target.value;
if (raw === '__serve_cookbook__') return;
const model = raw.includes('::') ? raw.slice(raw.indexOf('::') + 2) : '';
try {
status.textContent = 'Saving…';
await saveImagePreferences({ [key]: model });
status.textContent = 'Saved';
} catch (error) {
await syncModels();
status.textContent = error.message;
}
});
}
const onPreferencesChanged = event => applyModels(event.detail);
window.addEventListener('image-preferences-changed', onPreferencesChanged);
container.addEventListener('ge-image-models-ready', syncModels);
const strengthInput = el.querySelector('#ge-look-strength');
const strengthVal = el.querySelector('#ge-look-strength-val');
if (strengthInput) {
try {
const saved = parseFloat(localStorage.getItem('ge-look-strength'));
if (Number.isFinite(saved) && saved > 0) strengthInput.value = String(saved);
} catch (_) {}
if (strengthVal) strengthVal.textContent = Number(strengthInput.value).toFixed(1) + '×';
strengthInput.addEventListener('input', () => {
const v = Number(strengthInput.value);
if (strengthVal) strengthVal.textContent = v.toFixed(1) + '×';
try { localStorage.setItem('ge-look-strength', String(v)); } catch (_) {}
});
}
function destroy() {
window.removeEventListener('keydown', onKey, true);
window.removeEventListener('image-preferences-changed', onPreferencesChanged);
container.removeEventListener('ge-image-models-ready', syncModels);
el.remove();
}
return { element: el, open, close, destroy };
}