refactor(admin): open System for settings and keep page options

Settings opens System › Overview in a new tab. The settings dialog becomes Page
options (opened from the list header) with only the Infinite Scrolling default,
and its Save posts {"infinite_scroll": …} alone, so a stale legacy form can no
longer overwrite scan folders or other values edited in System (#330 S13-9).
Scan folders, monitoring status, auto-hide, display mode, worker threads,
LoraManager and the LoRA import leave the page (rows B8–B14).
This commit is contained in:
Vito Sansevero
2026-10-27 18:44:30 -07:00
parent 0abdcbc814
commit ba2803dbbf
5 changed files with 72 additions and 471 deletions
+7
View File
@@ -4125,6 +4125,13 @@ legacy page workflows), not by this check.
| Log files list and download | admin | `#downloadLogsBtn`, `#logFilesList` | `loadLogFiles()`, `downloadLogs()`, `downloadLogFile()` | each listed file downloads by its returned name and a link has no Download (workspace-system-diagnostics.spec.mjs) |
| Clear/truncate logs | admin | `#clearLogsBtn` | `clearLogs()` | Clear all logs states both counts, confirms first and reads everything again (workspace-system-diagnostics.spec.mjs) |
| Set log level (incl. CRITICAL), console logging | admin | `#updateLogConfigBtn`, `#setLogLevel`, `#consoleLogging` | `loadLogConfig()`, `updateLogConfig()` | runtime level (including CRITICAL) and console logging save and say they apply until the server restarts (workspace-system-diagnostics.spec.mjs) |
| Image scan directories add/remove | admin | `#scanPathsList`, `#addScanPathBtn` | `renderScanPaths()`, `addScanPath()`, `removeScanPath()`, `_collectScanPaths()` | Add source validates on the server, then marks the host draft dirty; Save persists (workspace-system-sources.spec.mjs) |
| Currently monitoring list + Refresh | admin | `#refreshMonitoringStatus`, `#monitoringStatus` | `updateMonitoringStatus()` | watching only with a live observer; restart pending otherwise (workspace-system-sources.spec.mjs) |
| Search results auto-hide `result_timeout` | admin | `#resultTimeout` | — | host fields round-trip through /settings with revision (workspace-system.spec.mjs) |
| Web UI display mode | admin | `#webuiDisplayMode` | — | host fields round-trip through /settings with revision (workspace-system.spec.mjs) |
| Worker threads | admin | `#workerThreads`, `#workerThreadsValue` | `renderWorkerThreads()` | worker range max is cpu_count (workspace-system-sources.spec.mjs) |
| LoraManager badge, enable, path, trigger words, CivitAI key, clear key | admin | `#loraDetectionBadge`, `#loraEnabled`, `#loraSettings`, `#loraManagerPath`, `#loraTriggerWords`, `#civitaiApiKey`, `#clearCivitaiKey` | `detectLoraManager()`, `_bindLoraEvents()`, `saveLoraSettings()` | LoRA rows show detected / enabled / cache-loaded separately (workspace-system-integrations.spec.mjs) |
| LoRA data import with progress | admin | `#loraImportModal`, `#loraImportBtn`, `#loraImportStatus`, `#loraImportPercent`, `#loraImportBar`, `#loraImportProcessed`, `#loraImportImported` | `runLoraImport()` | Review import shows replacement, blocked items and network effects with the backup box; Start opens the job (D107) (workspace-system-integrations.spec.mjs) |
### Browser cache ownership (#222)
@@ -18,6 +18,7 @@ const LABELS = Object.freeze({
// One row per System link of the design's §2 map: the page, the control's label, how to find it, the section it opens
// and what a test must arrange before the control shows.
const LINKS = Object.freeze([
{ page: 'admin', label: 'Settings', locate: page => page.locator('#settingsBtn'), section: 'overview' },
{ page: 'admin', label: 'Diagnostics', locate: page => page.locator('#diagnosticsBtn'), section: 'diagnostics' },
{ page: 'admin', label: 'View Logs', locate: page => page.locator('#logsBtn'), section: 'diagnostics' },
]);
@@ -155,3 +156,29 @@ test('kept admin flows still work', async ({ page, context }) => {
await tab.close();
}
});
test('Page options keeps the page-only settings admin', async ({ page, request }) => {
const before = (await (await request.get('/prompt_manager/settings')).json()).settings;
await page.goto(PAGES.admin);
await expect(page.locator('#resultsList .prompt-text').first()).toBeVisible();
await page.locator('#pageOptionsBtn').click();
const dialog = page.locator('#settingsModal');
await expect(dialog).toBeVisible();
await expect(dialog.locator('h3')).toHaveText('Page options');
// Exactly the page-only setting (G5): the list's Infinite Scrolling default.
expect(await dialog.locator('input, select, textarea').evaluateAll(fields => fields.map(field => field.id)))
.toEqual(['infiniteScrollDefault']);
await expect(page.locator('#infiniteScrollDefault')).not.toBeChecked();
await page.locator('#infiniteScrollDefault').check();
const saved = page.waitForRequest(item => item.method() === 'POST' && item.url().endsWith('/prompt_manager/settings'));
await page.locator('#saveSettings').click();
expect((await saved).postDataJSON()).toEqual({ infinite_scroll: true });
await expect(dialog).toBeHidden();
// The list header follows the saved default at once; nothing else changed.
await expect(page.locator('#infiniteScrollSession')).toBeChecked();
const after = (await (await request.get('/prompt_manager/settings')).json()).settings;
expect(after).toEqual({ ...before, infinite_scroll: true });
await page.reload();
await page.locator('#pageOptionsBtn').click();
await expect(page.locator('#infiniteScrollDefault')).toBeChecked();
});
+22 -18
View File
@@ -146,28 +146,12 @@ test('gallery cache clear says so when browser storage is denied', async () => {
'Thumbnails cleared; original images untouched. This browser refused access to its storage, so nothing there was cleared.']]);
});
test('admin LoRA import shows a refusal frame and closes its modal', async () => {
const { page, dom, timers, notifications } = load('admin.js', 'PromptAdmin',
async () => sseResponse([{ type: 'error', status: BUSY, progress: 0, message: BUSY }]));
page.saveLoraSettings = async () => true;
page.loadStatistics = () => assert.fail('a refused import must not reload statistics');
await page.runLoraImport();
assert.equal(dom.nodes.loraImportStatus.textContent, `Error: ${BUSY}`);
assert.deepEqual(notifications.filter(([type]) => type === 'error'), [['error', `LoRA import failed: ${BUSY}`]]);
assert.equal(timers.length, 1);
timers[0]();
assert.deepEqual(notifications.at(-1), ['hide', 'loraImportModal']);
});
test('admin settings save shows why config.json refused it', async () => {
const message = 'config.json cannot be read, so nothing was saved. Fix or remove the file, then save again.';
const { page, notifications } = load('admin.js', 'PromptAdmin',
async () => jsonResponse(409, { success: false, persisted: false, code: 'config-unreadable', error: message }));
page.settings = {};
page._collectScanPaths = () => [];
page.saveLoraSettings = async () => assert.fail('nothing else is saved after a refusal');
page.setInfiniteScroll = () => assert.fail('nothing changes after a refusal');
await page.saveSettings();
@@ -178,13 +162,33 @@ test('admin settings save keeps its plain message when the reply has no reason',
const { page, notifications } = load('admin.js', 'PromptAdmin', async () => ({ ok: false, status: 500,
json: async () => { throw new SyntaxError('not json'); } }));
page.settings = {};
page._collectScanPaths = () => [];
await page.saveSettings();
assert.deepEqual(notifications, [['error', 'Failed to save settings']]);
});
// #330 S13-9: the admin page's reduced Page options save sends only the list's scroll default, so a stale legacy form
// can no longer overwrite scan folders or other settings edited in System.
test('admin Page options save posts only infinite_scroll', async () => {
const sent = [];
const { page, dom, notifications } = load('admin.js', 'PromptAdmin', async (url, init) => {
sent.push([url, init.method, JSON.parse(init.body)]);
return jsonResponse(200, { success: true, restart_required: true });
});
page.settings = { infiniteScroll: false };
const scrolled = [];
page.setInfiniteScroll = (on, options) => scrolled.push([on, { ...options }]);
dom.getElementById('infiniteScrollDefault').checked = true;
await page.saveSettings();
assert.deepEqual(sent, [['/prompt_manager/settings', 'POST', { infinite_scroll: true }]]);
assert.deepEqual(scrolled, [[true, { session: false }]]);
assert.equal(page.settings.infiniteScroll, true);
assert.deepEqual(notifications, [['success', 'Page options saved'], ['hide', 'settingsModal']]);
});
test('admin maintenance names the job that refused it', async () => {
const { page, dom, notifications } = load('admin.js', 'PromptAdmin',
async () => jsonResponse(409, { success: false, error: BUSY }), {
+9 -151
View File
@@ -164,10 +164,11 @@
class="px-2.5 py-1 bg-pm-surface border border-pm hover:bg-pm-hover text-pm text-xs font-medium rounded-pm-sm transition-colors">
Export
</button>
<button id="settingsBtn"
class="px-2.5 py-1 bg-pm-surface border border-pm hover:bg-pm-hover text-pm text-xs font-medium rounded-pm-sm transition-colors">
<a id="settingsBtn" href="workspace#/system/overview" target="_blank" rel="noopener"
title="Opens System › Overview in a new tab"
class="px-2.5 py-1 bg-pm-surface border border-pm hover:bg-pm-hover text-pm text-xs font-medium rounded-pm-sm transition-colors">
Settings
</button>
</a>
<a id="diagnosticsBtn" href="workspace#/system/diagnostics" target="_blank" rel="noopener"
title="Opens System › Diagnostics &amp; logs in a new tab"
class="px-2.5 py-1 bg-pm-surface border border-pm hover:bg-pm-hover text-pm text-xs font-medium rounded-pm-sm transition-colors">
@@ -231,11 +232,12 @@
</select>
<span class="text-xs text-pm-secondary">per page</span>
</div>
<!-- Session-only toggle; the saved default lives in Settings -->
<!-- Session-only toggle; the saved default lives in Page options -->
<label class="flex items-center gap-1.5 text-xs text-pm-secondary cursor-pointer" title="Load the next page as you scroll (this session)">
<input type="checkbox" id="infiniteScrollSession" class="w-3.5 h-3.5 rounded border-pm bg-pm-input text-pm-accent focus:ring-pm-accent">
Infinite scroll
</label>
<button id="pageOptionsBtn" type="button" class="text-xs text-pm-secondary hover:text-pm-accent underline">Page options</button>
</div>
<span class="text-xs font-medium text-pm-secondary bg-pm-input px-2 py-0.5 rounded-full" id="resultsCount">Loading...</span>
</div>
@@ -369,61 +371,13 @@
<!-- Settings Modal -->
<div id="settingsModal" class="fixed inset-0 bg-black/60 backdrop-blur-[2px] hidden items-center justify-center z-50">
<div class="bg-pm-surface rounded-pm-lg p-4 max-w-lg w-full mx-4 border border-pm max-h-[90vh] overflow-y-auto shadow-pm">
<h3 class="text-sm font-semibold text-pm mb-4">Settings</h3>
<h3 class="text-sm font-semibold text-pm mb-4">Page options</h3>
<div class="space-y-4">
<!-- Gallery Settings Section -->
<div class="border-b border-pm pb-3">
<h4 class="text-xs font-semibold text-pm-secondary mb-3 uppercase tracking-wide">Gallery Settings</h4>
<h4 class="text-xs font-semibold text-pm-secondary mb-3 uppercase tracking-wide">Prompt list</h4>
<div class="mb-3">
<label class="block text-xs font-medium text-pm-secondary mb-1">Image Scan Directories</label>
<div id="scanPathsList" class="space-y-2">
<!-- Populated dynamically by JS -->
</div>
<button id="addScanPathBtn" type="button"
class="mt-2 flex items-center gap-1 text-xs text-pm-accent hover:text-pm-accent-hover">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Add Directory
</button>
<p class="text-xs text-pm-muted mt-1">Directories to scan for images. Leave empty to use ComfyUI default output directory.</p>
</div>
<div class="mb-3">
<div class="flex items-center justify-between">
<label class="block text-xs font-medium text-pm-secondary">Currently Monitoring</label>
<button id="refreshMonitoringStatus" class="text-xs text-pm-accent hover:text-pm-accent-hover">Refresh</button>
</div>
<div id="monitoringStatus" class="mt-1.5 px-2.5 py-1.5 bg-pm-input rounded-pm-sm text-xs text-pm-secondary font-mono">
Loading...
</div>
</div>
</div>
<!-- UI Settings Section -->
<div class="border-b border-pm pb-3">
<h4 class="text-xs font-semibold text-pm-secondary mb-3 uppercase tracking-wide">UI Settings</h4>
<div class="mb-3">
<label class="block text-xs font-medium text-pm-secondary mb-1">Search Results Auto-Hide (seconds)</label>
<input type="number" id="resultTimeout" min="0" max="300" value="30"
class="w-full px-2.5 py-1.5 bg-pm-input border border-pm rounded-pm-sm text-pm text-[13px] focus:outline-none">
<p class="text-xs text-pm-muted mt-1">Set to 0 to disable auto-hide.</p>
</div>
<div>
<label class="block text-xs font-medium text-pm-secondary mb-1">Web UI Display Mode</label>
<select id="webuiDisplayMode"
class="w-full px-2.5 py-1.5 bg-pm-input border border-pm rounded-pm-sm text-pm text-[13px] focus:outline-none">
<option value="popup">Popup Window (Current)</option>
<option value="newtab">New Tab</option>
</select>
<p class="text-xs text-pm-muted mt-1">Choose how the Web UI opens from ComfyUI nodes.</p>
</div>
<div class="mt-3 flex items-center justify-between">
<div class="flex items-center justify-between">
<div>
<label for="infiniteScrollDefault" class="block text-xs font-medium text-pm-secondary">Infinite Scrolling</label>
<p class="text-xs text-pm-muted">Load the next page of prompts as you scroll. The checkbox in the list header overrides this for the current session.</p>
@@ -431,76 +385,6 @@
<input type="checkbox" id="infiniteScrollDefault" class="w-4 h-4 rounded border-pm bg-pm-input text-pm-accent focus:ring-pm-accent ml-3">
</div>
</div>
<!-- Performance Section -->
<div class="border-b border-pm pb-3">
<h4 class="text-xs font-semibold text-pm-secondary mb-3 uppercase tracking-wide">Performance</h4>
<div>
<div class="flex items-center justify-between mb-1.5">
<label for="workerThreads" class="text-xs font-medium text-pm-secondary">Worker threads</label>
<span id="workerThreadsValue" class="text-pm-accent text-xs font-mono">1 / 1 cores</span>
</div>
<input type="range" id="workerThreads" min="1" max="1" step="1" value="1"
class="w-full h-1.5 bg-pm-primary rounded-lg appearance-none cursor-pointer accent-[var(--pm-accent)]">
<p class="text-xs text-pm-muted mt-1">Threads used by the image rescan and thumbnail generation. More threads finish sooner; fewer leave CPU for ComfyUI while it generates.</p>
</div>
</div>
<!-- Integrations Section -->
<div class="border-b border-pm pb-3">
<h4 class="text-xs font-semibold text-pm-secondary mb-3 uppercase tracking-wide">Integrations</h4>
<div class="mb-3">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<label class="block text-xs font-medium text-pm-secondary">LoraManager</label>
<span id="loraDetectionBadge" class="text-[10px] px-1.5 py-0.5 rounded-full bg-pm-input text-pm-muted">checking...</span>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="loraEnabled" class="sr-only peer" disabled>
<div class="w-8 h-4 bg-pm-input rounded-full peer peer-checked:bg-pm-accent transition-colors pointer-events-none after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:rounded-full after:h-3 after:w-3 after:transition-all peer-checked:after:translate-x-4"></div>
</label>
</div>
<div id="loraSettings" class="hidden space-y-2 mt-2 pl-2 border-l-2 border-pm">
<div>
<label class="block text-xs font-medium text-pm-secondary mb-1">Path</label>
<input type="text" id="loraManagerPath" readonly
class="w-full px-2.5 py-1.5 bg-pm-input border border-pm rounded-pm-sm text-pm text-[13px] text-pm-muted">
</div>
<div class="flex items-center justify-between">
<label class="block text-xs font-medium text-pm-secondary">Auto-inject trigger words</label>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="loraTriggerWords" class="sr-only peer">
<div class="w-8 h-4 bg-pm-input rounded-full peer peer-checked:bg-pm-accent transition-colors pointer-events-none after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:rounded-full after:h-3 after:w-3 after:transition-all peer-checked:after:translate-x-4"></div>
</label>
</div>
<p class="text-xs text-pm-muted">Automatically append trigger words when <code class="text-pm-accent">&lt;lora:name:weight&gt;</code> is detected in prompts.</p>
<div class="mt-2">
<label class="block text-xs font-medium text-pm-secondary mb-1">CivitAI API Key</label>
<input type="password" id="civitaiApiKey" placeholder="Optional — required for NSFW example images"
class="w-full px-2.5 py-1.5 bg-pm-input border border-pm rounded-pm-sm text-pm text-[13px] placeholder:text-pm-muted focus:outline-none">
<p class="text-xs text-pm-muted mt-1">Get your key from <a href="https://civitai.com/user/account" target="_blank" class="text-pm-accent hover:underline">civitai.com/user/account</a></p>
</div>
<label class="mt-1 flex items-center gap-1.5 text-xs text-pm-secondary cursor-pointer">
<input type="checkbox" id="clearCivitaiKey" class="w-3.5 h-3.5 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent">
<span>Clear the stored key on save</span>
</label>
<button id="loraImportBtn" type="button"
class="mt-1 flex items-center gap-1 text-xs text-pm-accent hover:text-pm-accent-hover">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
</svg>
Import LoRA Data
</button>
</div>
</div>
</div>
</div>
<div class="flex justify-end gap-2 mt-4">
@@ -516,32 +400,6 @@
</div>
</div>
<!-- LoRA Import Progress Modal -->
<div id="loraImportModal" class="fixed inset-0 bg-black/60 backdrop-blur-[2px] hidden items-center justify-center z-50">
<div class="bg-pm-surface rounded-pm-lg p-4 max-w-md w-full mx-4 border border-pm shadow-pm">
<h3 class="text-sm font-semibold text-pm mb-4">Importing LoRA Data</h3>
<div class="mb-3">
<p class="text-pm-secondary text-xs">Status:</p>
<p id="loraImportStatus" class="text-pm text-xs font-medium truncate">Initializing...</p>
</div>
<div class="mb-3">
<div class="flex justify-between text-xs text-pm-secondary mb-1">
<span>Progress</span>
<span id="loraImportPercent">0%</span>
</div>
<div class="w-full bg-pm-input rounded-full h-1.5">
<div id="loraImportBar" class="bg-pm-accent h-1.5 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<div class="flex justify-between text-[11px] text-pm-muted mt-1">
<span id="loraImportProcessed">0 processed</span>
<span id="loraImportImported">0 imported</span>
</div>
</div>
</div>
</div>
<!-- Bulk Tag Modal -->
<div id="bulkTagModal" class="fixed inset-0 bg-black/60 backdrop-blur-[2px] hidden items-center justify-center z-50">
<div class="bg-pm-surface rounded-pm-lg p-4 max-w-md w-full mx-4 border border-pm shadow-pm">
+7 -302
View File
@@ -3,11 +3,8 @@
this.prompts = [];
this.listMode = "recent"; // "recent" or "search"; a sort change reloads it
this.selectedPrompts = new Set();
// The page's own options (Page options); every other setting lives in System.
this.settings = {
resultTimeout: 5,
webuiDisplayMode: 'popup',
workerThreads: 1,
cpuCount: 1,
infiniteScroll: false,
};
this.categories = [];
@@ -125,7 +122,7 @@
document.getElementById("bulkTagBtn").addEventListener("click", () => this.showBulkTagModal());
document.getElementById("bulkCategoryBtn").addEventListener("click", () => this.showBulkCategoryModal());
document.getElementById("exportBtn").addEventListener("click", () => this.exportPrompts());
document.getElementById("settingsBtn").addEventListener("click", () => this.showSettingsModal());
document.getElementById("pageOptionsBtn").addEventListener("click", () => this.showSettingsModal());
document.getElementById("statsBtn").addEventListener("click", () => this.showStats());
document.getElementById("maintenanceBtn").addEventListener("click", () => this.showMaintenanceModal());
document.getElementById("backupBtn").addEventListener("click", () => this.backupDatabase());
@@ -193,8 +190,6 @@
// Settings modal
document.getElementById("saveSettings").addEventListener("click", () => this.saveSettings());
document.getElementById("cancelSettings").addEventListener("click", () => this.hideModal("settingsModal"));
document.getElementById("refreshMonitoringStatus").addEventListener("click", () => this.updateMonitoringStatus());
document.getElementById("addScanPathBtn").addEventListener("click", () => this.addScanPath());
// Bulk tag modal
document.getElementById("confirmBulkTag").addEventListener("click", () => this.confirmBulkTag());
@@ -249,7 +244,6 @@
document.getElementById('wd14GeneralThreshold').addEventListener('input', (e) => {
document.getElementById('wd14GeneralThresholdValue').textContent = parseFloat(e.target.value).toFixed(2);
});
document.getElementById("workerThreads").addEventListener("input", () => this.renderWorkerThreads());
document.getElementById('wd14CharacterThreshold').addEventListener('input', (e) => {
document.getElementById('wd14CharacterThresholdValue').textContent = parseFloat(e.target.value).toFixed(2);
});
@@ -298,21 +292,9 @@
if (response.ok) {
const data = await response.json();
if (data.success && data.settings) {
this.settings.resultTimeout = data.settings.result_timeout ?? 5;
this.settings.webuiDisplayMode = data.settings.webui_display_mode || 'popup';
this.settings.infiniteScroll = data.settings.infinite_scroll === true;
// The saved default seeds the session until the header checkbox is used
if (!this.infiniteScrollChosen) this.setInfiniteScroll(this.settings.infiniteScroll, { session: false });
this.settings.cpuCount = Math.max(1, parseInt(data.settings.cpu_count, 10) || 1);
this.settings.workerThreads = Math.min(
this.settings.cpuCount, Math.max(1, parseInt(data.settings.worker_threads, 10) || 1)
);
this.settings.monitoredDirectories = data.settings.monitored_directories || [];
this.settings.galleryRootPaths = data.settings.gallery_root_paths || [];
// Backward compat: if server only returned old field
if (!this.settings.galleryRootPaths.length && data.settings.gallery_root_path) {
this.settings.galleryRootPaths = [data.settings.gallery_root_path];
}
}
}
} catch (error) {
@@ -996,255 +978,29 @@
});
}
renderWorkerThreads() {
const threads = document.getElementById("workerThreads");
document.getElementById("workerThreadsValue").textContent =
`${threads.value} / ${this.settings.cpuCount} cores`;
}
/** Page options: the prompt list's saved Infinite Scrolling default (scan folders and the rest are in System). */
showSettingsModal() {
document.getElementById("resultTimeout").value = this.settings.resultTimeout;
document.getElementById("webuiDisplayMode").value = this.settings.webuiDisplayMode;
document.getElementById("infiniteScrollDefault").checked = this.settings.infiniteScroll;
const threads = document.getElementById("workerThreads");
threads.max = this.settings.cpuCount;
threads.value = this.settings.workerThreads;
this.renderWorkerThreads();
this.renderScanPaths();
this.updateMonitoringStatus();
this.detectLoraManager();
this.showModal("settingsModal");
}
async updateMonitoringStatus() {
const statusEl = document.getElementById("monitoringStatus");
try {
const response = await ApiClient.fetchUrl("/prompt_manager/settings");
if (response.ok) {
const data = await response.json();
const dirs = data.settings?.monitored_directories || [];
if (dirs.length > 0) {
statusEl.innerHTML = dirs.map(d => `<div class="truncate" title="${escapeHtml(d)}">✓ ${escapeHtml(d)}</div>`).join('');
} else {
statusEl.textContent = 'No directories being monitored (auto-detect on restart)';
}
}
} catch (error) {
statusEl.textContent = 'Unable to fetch status';
}
}
// ── LoraManager integration ──────────────────────────────
async detectLoraManager() {
const badge = document.getElementById("loraDetectionBadge");
const toggle = document.getElementById("loraEnabled");
const settings = document.getElementById("loraSettings");
try {
// First check current status
const statusRes = await ApiClient.fetchUrl("/prompt_manager/lora/status");
if (statusRes.ok) {
const status = await statusRes.json();
if (status.success && status.enabled) {
badge.textContent = "enabled";
badge.className = "text-[10px] px-1.5 py-0.5 rounded-full bg-green-500/20 text-green-400";
toggle.checked = true;
toggle.disabled = false;
document.getElementById("loraManagerPath").value = status.path;
document.getElementById("loraTriggerWords").checked = status.trigger_words_enabled;
const keyInput = document.getElementById("civitaiApiKey");
keyInput.value = "";
keyInput.placeholder = status.has_civitai_api_key
? "A key is stored — leave empty to keep it"
: "Optional — required for NSFW example images";
document.getElementById("clearCivitaiKey").checked = false;
settings.classList.remove("hidden");
this._loraPath = status.path;
this._bindLoraEvents();
return;
}
}
// Try auto-detection
const detectRes = await ApiClient.fetchUrl("/prompt_manager/lora/detect");
if (!detectRes.ok) return;
const data = await detectRes.json();
if (data.detected) {
badge.textContent = "detected";
badge.className = "text-[10px] px-1.5 py-0.5 rounded-full bg-blue-500/20 text-blue-400";
toggle.disabled = false;
document.getElementById("loraManagerPath").value = data.path;
this._loraPath = data.path;
} else {
badge.textContent = "not installed";
badge.className = "text-[10px] px-1.5 py-0.5 rounded-full bg-pm-input text-pm-muted";
toggle.disabled = true;
}
this._bindLoraEvents();
} catch (e) {
badge.textContent = "error";
badge.className = "text-[10px] px-1.5 py-0.5 rounded-full bg-red-500/20 text-red-400";
}
}
_bindLoraEvents() {
if (this._loraBound) return;
this._loraBound = true;
const toggle = document.getElementById("loraEnabled");
const settings = document.getElementById("loraSettings");
toggle.addEventListener("change", () => {
if (toggle.checked) {
settings.classList.remove("hidden");
} else {
settings.classList.add("hidden");
}
});
document.getElementById("loraImportBtn").addEventListener("click", () => {
this.runLoraImport();
});
}
async saveLoraSettings() {
const enabled = document.getElementById("loraEnabled").checked;
const triggerWords = document.getElementById("loraTriggerWords").checked;
const civitaiKey = document.getElementById("civitaiApiKey").value.trim();
const clearKey = document.getElementById("clearCivitaiKey").checked;
const path = this._loraPath || "";
// The server never returns the stored key: an empty field keeps it, the
// checkbox removes it, and a non-empty field replaces it
const payload = { enabled, path, trigger_words_enabled: triggerWords };
if (clearKey) payload.clear_civitai_api_key = true;
else if (civitaiKey) payload.civitai_api_key = civitaiKey;
try {
const res = await ApiClient.fetchUrl("/prompt_manager/lora/enable", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!data.success) {
this.showNotification(data.error || "Failed to save LoRA settings", "error");
return false;
}
return true;
} catch (e) {
this.showNotification("Failed to save LoRA settings", "error");
return false;
}
}
async runLoraImport() {
const saved = await this.saveLoraSettings();
if (!saved) return;
// Close settings and show progress modal
this.hideModal("settingsModal");
document.getElementById("loraImportStatus").textContent = "Initializing...";
document.getElementById("loraImportBar").style.width = "0%";
document.getElementById("loraImportPercent").textContent = "0%";
document.getElementById("loraImportProcessed").textContent = "0 processed";
document.getElementById("loraImportImported").textContent = "0 imported";
this.showModal("loraImportModal");
try {
const res = await ApiClient.fetchUrl("/prompt_manager/lora/scan", { method: "POST" });
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n\n");
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const data = JSON.parse(line.slice(6));
const pct = data.progress || 0;
document.getElementById("loraImportBar").style.width = `${pct}%`;
document.getElementById("loraImportPercent").textContent = `${pct}%`;
if (data.status) {
document.getElementById("loraImportStatus").textContent = data.status;
}
if (data.processed !== undefined) {
document.getElementById("loraImportProcessed").textContent = `${data.processed} processed`;
}
if (data.imported !== undefined) {
document.getElementById("loraImportImported").textContent = `${data.imported} imported`;
}
if (data.type === "complete") {
document.getElementById("loraImportStatus").textContent =
`Done — ${data.imported} imported, ${data.skipped} skipped`;
this.loadStatistics();
setTimeout(() => this.hideModal("loraImportModal"), 2000);
} else if (data.type === "error") {
// A refusal (another job runs) or a failed import: name it and close the modal.
document.getElementById("loraImportStatus").textContent = `Error: ${data.message}`;
this.showNotification(`LoRA import failed: ${data.message}`, "error");
setTimeout(() => this.hideModal("loraImportModal"), 3000);
}
} catch (e) { /* skip malformed SSE */ }
}
}
} catch (e) {
document.getElementById("loraImportStatus").textContent = `Error: ${e.message}`;
setTimeout(() => this.hideModal("loraImportModal"), 3000);
}
}
/** Saves Page options: only `infinite_scroll`, so a stale page never overwrites what System saved (#330 S13-9). */
async saveSettings() {
const timeout = parseInt(document.getElementById("resultTimeout").value);
const displayMode = document.getElementById("webuiDisplayMode").value;
const workerThreads = parseInt(document.getElementById("workerThreads").value, 10) || 1;
const infiniteScroll = document.getElementById("infiniteScrollDefault").checked;
const galleryPaths = this._collectScanPaths().filter(p => p !== '');
this.settings.resultTimeout = timeout;
this.settings.webuiDisplayMode = displayMode;
this.settings.workerThreads = workerThreads;
this.settings.infiniteScroll = infiniteScroll;
this.settings.galleryRootPaths = galleryPaths;
try {
const response = await ApiClient.fetchUrl("/prompt_manager/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
result_timeout: timeout,
webui_display_mode: displayMode,
worker_threads: workerThreads,
infinite_scroll: infiniteScroll,
gallery_root_paths: galleryPaths
}),
body: JSON.stringify({ infinite_scroll: infiniteScroll }),
});
if (response.ok) {
const data = await response.json();
// Save LoRA integration settings (fire-and-forget)
await this.saveLoraSettings();
this.settings.infiniteScroll = infiniteScroll;
this.setInfiniteScroll(infiniteScroll, { session: false });
if (data.restart_required) {
this.showNotification("Settings saved. Restart ComfyUI for gallery path changes to take effect.", "warning");
} else {
this.showNotification("Settings saved successfully", "success");
}
this.showNotification("Page options saved", "success");
this.hideModal("settingsModal");
} else {
// A refusal names its reason (a corrupt config.json, a changed file): show it.
@@ -1257,57 +1013,6 @@
}
}
renderScanPaths() {
const container = document.getElementById("scanPathsList");
const paths = this.settings.galleryRootPaths || [];
container.textContent = ''; // Clear safely
const toRender = paths.length > 0 ? paths : [''];
toRender.forEach((p, i) => {
const row = document.createElement('div');
row.className = 'flex gap-2 items-center';
row.dataset.pathIndex = i;
const input = document.createElement('input');
input.type = 'text';
input.value = p;
input.placeholder = 'Leave empty for auto-detect';
input.className = 'flex-1 px-2.5 py-1.5 bg-pm-input border border-pm rounded-pm-sm text-pm text-[13px] focus:outline-none scan-path-input';
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'text-pm-muted hover:text-red-400 p-1 text-lg leading-none';
removeBtn.title = 'Remove';
removeBtn.textContent = '\u00d7'; // multiplication sign as X
removeBtn.addEventListener('click', () => this.removeScanPath(i));
row.appendChild(input);
row.appendChild(removeBtn);
container.appendChild(row);
});
}
addScanPath() {
const paths = this._collectScanPaths();
paths.push('');
this.settings.galleryRootPaths = paths;
this.renderScanPaths();
const inputs = document.querySelectorAll('.scan-path-input');
if (inputs.length) inputs[inputs.length - 1].focus();
}
removeScanPath(index) {
const paths = this._collectScanPaths();
paths.splice(index, 1);
this.settings.galleryRootPaths = paths;
this.renderScanPaths();
}
_collectScanPaths() {
return Array.from(document.querySelectorAll('.scan-path-input'))
.map(input => input.value.trim());
}
toggleSelectAll(checked) {
document.querySelectorAll(".prompt-checkbox").forEach((checkbox) => {
checkbox.checked = checked;