* test(legacy): snapshot the routes and the settings response Pins every registered route and the key paths of GET /prompt_manager/settings while System S13 (#330) moves the legacy pages' tools to native System. Recorded on the unchanged main; cutover (#334) removes it. * test(legacy): prove each removed legacy control has native coverage TESTING.md gains the "Legacy System UI removed by S13" table (no rows yet). legacy-coverage.test.mjs fails when a removal cites a capability that is not covered, copies another native test, lists an id or method twice, or leaves the id or method on its page. legacy-system-links.spec.mjs holds the System link table and guards the kept admin flows. * refactor(admin): open System for Diagnostics and logs The admin page's Diagnostics and View Logs buttons become links that open System › Diagnostics & logs in a new tab. The legacy Diagnostics and System Logs dialogs and their methods go; TESTING.md lists them against their covered capabilities (rows B1–B7). * 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). * refactor(admin): open System for maintenance Maintenance opens System › Maintenance in a new tab, where each operation runs as a reviewed Activity job. The one-click legacy Database Maintenance dialog and its methods go (rows B15–B18); formatFileSize stays for the image modal. * refactor(admin): open System for backup and restore Backup DB and Restore DB open System › Backup & export in a new tab, where a restore is staged, reviewed and run as an Activity job. The one-click Restore dialog and its methods go (rows B19–B20); the scan dialog's quick backup stays. The removed browser tests' data checks live on at the route: a new round trip test restores every prompt with its tags and image links. * test(legacy): find a removed modal id passed to showModal and settle the links spec The removal check also finds an id passed as a string to a ...Modal() call and no longer reads data-id= as id=. The no-request test waits for the page's own start-up reads to finish, the sort check compares as the server sorts, and the kept-flow test checks the added prompt shows in the list. * test(admin): open Page options after the saved default is read Drops the leftover LoraManager section comment above the Page options save. * refactor(gallery): open System for settings and maintenance The gallery's Settings opens System › Appearance in a new tab. Its settings dialog becomes Page options (a new header button) with only the page's own settings: thumbnail startup check, lazy loading, items per page, grid columns, infinite scrolling, debug mode and API timeout. Save and Reset keep every other stored key, so the Appearance values System saves survive (S13-13). Generate Thumbnails, Clear cache, Rescan and Find duplicates leave the page; Page options links System › Maintenance and Sources & scans (rows B21–B28). * refactor(autotag): manage tagging models in System In both auto-tag dialogs a model that is not downloaded shows a Manage in System link to System › Auto-tagging (a new tab) instead of a Download button, and the gallery's Unload Model becomes the same link. The legacy download dialog and the download, cancel and unload methods go (rows B29–B31); the batch and review stay. A test hook marks a fake model loaded. * docs(system): name System as the home for settings and maintenance README and TESTING.md send settings, maintenance, backups, diagnostics, the gallery's tools and auto-tag model downloads to their System section and name each legacy page's Page options. * fix(gallery): keep the default view in Page options and save viewer choices alone System › Appearance's Default layout is the workspace's own key, so the legacy gallery's Default View Mode stays a page option instead of losing its only control. The viewer's mute and loop buttons remember just that choice instead of saving the Page options dialog's unfilled inputs over the stored page options. The pending-thumbnails toast names System › Sources & scans. * test(legacy): check each System link sends no request in its own test The no-request check runs inside each link's test, so a slow page no longer times out the whole table, and it ignores lazy thumbnail loads the layout starts on its own. * test(legacy): a lazy thumbnail load is not a System link's request * fix(gallery): let the header buttons wrap on a narrow screen With Page options beside Settings the header no longer fits a phone's width; it wrapped off the page and covered the Page options dialog in the touch runs. * feat(gallery): remove Default View Mode from Page options (#330) System > Appearance's Default layout is the one default-layout control, as the S13 design's G5 says. The legacy gallery's Page options no longer show or save a default view and Reset no longer resets it. A stored legacy defaultViewMode still picks the view the gallery opens in (grid otherwise), and Save and Reset keep that key for the workspace-preferences migration.
3105 lines
156 KiB
JavaScript
3105 lines
156 KiB
JavaScript
class PromptAdmin {
|
||
constructor() {
|
||
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 = {
|
||
infiniteScroll: false,
|
||
};
|
||
this.categories = [];
|
||
this.tags = [];
|
||
this.subfolders = [];
|
||
|
||
// Pagination state
|
||
this.pagination = {
|
||
currentPage: 1,
|
||
limit: 50,
|
||
total: 0,
|
||
totalPages: 1
|
||
};
|
||
|
||
this.tagsPage = null;
|
||
|
||
this.api = ApiClient.createApiClient();
|
||
// The page's lifetime ends when it starts to leave. WebKit cancels the page's reads
|
||
// between beforeunload and pagehide; a save's follow-up read started then fails and is
|
||
// logged as a page error ("Fetch API cannot load … due to access control checks.").
|
||
this.startPageLifetime();
|
||
this.infiniteScroll = false; // session value; seeded from settings, toggled in the header
|
||
this.infiniteScrollChosen = false;
|
||
this.loadingMore = false;
|
||
// The prompt currently edited in place, if any: { promptId, element, originalText }
|
||
this.activeEdit = null;
|
||
|
||
// Elements rendered with data-action="..." (prompt cards, film strips, log files,
|
||
// review chips) are dispatched here instead of through inline onclick handlers,
|
||
// so user-provided strings never land inside JavaScript source.
|
||
this.actionHandlers = {
|
||
"copy-prompt": ({ promptId }) => this.copyPromptToClipboard(promptId),
|
||
"add-tags": ({ promptId }) => this.addTag(promptId),
|
||
"remove-tag": ({ promptId, tag }) => this.removeTag(promptId, tag),
|
||
"toggle-tags": ({ promptId }) => this.toggleMainTags(promptId),
|
||
gallery: ({ promptId }) => this.viewGallery(promptId),
|
||
edit: ({ promptId }) => this.editPrompt(promptId),
|
||
delete: ({ promptId }) => this.deletePrompt(promptId),
|
||
"open-film": ({ promptId, index }) => this.openFilmStripViewer(promptId, index),
|
||
"remove-review-tag": ({ index }) => this.removeReviewTag(index),
|
||
"pick-tag-suggestion": ({ tag }) => this.pickTagSuggestion(tag),
|
||
};
|
||
|
||
this.init();
|
||
}
|
||
|
||
init() {
|
||
this.bindEvents();
|
||
this.initRouter();
|
||
this.loadInitialData();
|
||
this.resumeRunningScan();
|
||
this.checkUpdateNotice();
|
||
}
|
||
|
||
initRouter() {
|
||
window.addEventListener('hashchange', () => this.handleRoute());
|
||
this.handleRoute();
|
||
}
|
||
|
||
handleRoute() {
|
||
if (ViewRouter.resolveView(window.location.hash) === "tags") {
|
||
this.showTagsPage();
|
||
} else {
|
||
this.showDashboard();
|
||
}
|
||
}
|
||
|
||
showDashboard() {
|
||
const dashboard = document.getElementById('dashboardView');
|
||
const tagsPage = document.getElementById('tagsPageView');
|
||
if (dashboard) dashboard.classList.remove('hidden');
|
||
if (tagsPage) tagsPage.classList.add('hidden');
|
||
if (this.tagsPage) {
|
||
this.tagsPage.destroy();
|
||
this.tagsPage = null;
|
||
}
|
||
}
|
||
|
||
showTagsPage() {
|
||
const dashboard = document.getElementById('dashboardView');
|
||
const tagsPage = document.getElementById('tagsPageView');
|
||
if (dashboard) dashboard.classList.add('hidden');
|
||
if (tagsPage) tagsPage.classList.remove('hidden');
|
||
|
||
// Lazy-initialize TagsPageManager
|
||
if (!this.tagsPage && typeof TagsPageManager !== 'undefined') {
|
||
this.tagsPage = new TagsPageManager(this);
|
||
} else if (this.tagsPage) {
|
||
this.tagsPage.loadTagsList();
|
||
}
|
||
}
|
||
|
||
bindEvents() {
|
||
// Search
|
||
document.getElementById("searchBtn").addEventListener("click", () => this.search());
|
||
document.getElementById("searchText").addEventListener("keyup", (e) => {
|
||
if (e.key === "Enter") this.search();
|
||
});
|
||
|
||
// Tag autocomplete: suggestions narrow to tags that co-occur with the ones typed
|
||
this.tagSuggest = { items: [], index: -1, request: ApiClient.latestOnly(), timer: null };
|
||
const tagsInput = document.getElementById("searchTags");
|
||
tagsInput.addEventListener("input", () => this.scheduleTagSuggestions());
|
||
tagsInput.addEventListener("focus", () => this.scheduleTagSuggestions(0));
|
||
tagsInput.addEventListener("keydown", (e) => this.handleTagSuggestKey(e));
|
||
tagsInput.addEventListener("blur", () => setTimeout(() => this.hideTagSuggestions(), 150));
|
||
// A click on a suggestion must not blur the input before the click lands
|
||
document.getElementById("searchTagSuggestions").addEventListener("mousedown", (e) => e.preventDefault());
|
||
|
||
// Bulk actions
|
||
document.getElementById("selectAll").addEventListener("change", (e) =>
|
||
this.toggleSelectAll(e.target.checked)
|
||
);
|
||
document.getElementById("bulkDeleteBtn").addEventListener("click", () => this.bulkDelete());
|
||
document.getElementById("bulkTagBtn").addEventListener("click", () => this.showBulkTagModal());
|
||
document.getElementById("bulkCategoryBtn").addEventListener("click", () => this.showBulkCategoryModal());
|
||
document.getElementById("exportBtn").addEventListener("click", () => this.exportPrompts());
|
||
document.getElementById("pageOptionsBtn").addEventListener("click", () => this.showSettingsModal());
|
||
document.getElementById("statsBtn").addEventListener("click", () => this.showStats());
|
||
document.getElementById("scanBtn").addEventListener("click", () => this.showScanModal());
|
||
document.getElementById("autoTagBtn").addEventListener("click", () => this.showAutoTagModal());
|
||
document.getElementById("addPromptBtn").addEventListener("click", () => this.showAddPromptModal());
|
||
document.getElementById("metadataBtn").addEventListener("click", () => this.openMetadataViewer());
|
||
document.getElementById("galleryBtn").addEventListener("click", () => this.openGallery());
|
||
|
||
// Pagination controls
|
||
document.getElementById("limitSelector").addEventListener("change", (e) => this.changeLimit(parseInt(e.target.value)));
|
||
document.getElementById("infiniteScrollSession").addEventListener("change", (e) => this.setInfiniteScroll(e.target.checked, { session: true }));
|
||
document.getElementById("resultsList").addEventListener("scroll", () => this.maybeLoadMorePrompts(), { passive: true });
|
||
document.getElementById("firstPageBtn").addEventListener("click", () => this.goToPage(1));
|
||
document.getElementById("prevPageBtn").addEventListener("click", () => this.goToPage(this.pagination.currentPage - 1));
|
||
document.getElementById("nextPageBtn").addEventListener("click", () => this.goToPage(this.pagination.currentPage + 1));
|
||
document.getElementById("lastPageBtn").addEventListener("click", () => this.goToPage(this.pagination.totalPages));
|
||
|
||
// Modals
|
||
// Output scan: a server-side job that survives modal dismissal and reloads
|
||
this.scanState = ScanProgress.initialState();
|
||
this.scanSource = null;
|
||
window.addEventListener("beforeunload", (e) => {
|
||
this.warnIfScanRunning(e);
|
||
// A page that asks to stay (a running scan) keeps its reads until it really goes.
|
||
if (!e.defaultPrevented) this.pageLifetime.abort();
|
||
});
|
||
window.addEventListener("pagehide", () => this.pageLifetime.abort());
|
||
// A page restored from the back/forward cache lives again, and so does one whose
|
||
// navigation never left (stopped, or answered with a download): its next input revives it.
|
||
window.addEventListener("pageshow", (e) => { if (e.persisted) this.revivePageLifetime(); });
|
||
for (const type of ["pointerdown", "keydown"]) {
|
||
window.addEventListener(type, () => this.revivePageLifetime(), true);
|
||
}
|
||
|
||
this.bindModalEvents();
|
||
|
||
// One delegated click handler for every data-action element; images declare a
|
||
// data-fallback-src instead of an inline onerror handler
|
||
document.addEventListener("click", (e) => this.handleActionClick(e));
|
||
document.addEventListener("error", (e) => this.applyImageFallback(e.target), true);
|
||
|
||
// In-place prompt editing: one keydown/focusout pair for the whole list, so
|
||
// listeners are not stacked per edit and a save is sent exactly once
|
||
const results = document.getElementById("resultsList");
|
||
results.addEventListener("keydown", (e) => this.handleEditKeydown(e));
|
||
results.addEventListener("focusout", (e) => this.handleEditFocusOut(e));
|
||
|
||
// Auto-search on filter changes
|
||
["searchCategory", "searchFolder"].forEach((id) => {
|
||
document.getElementById(id).addEventListener("change", () => this.search());
|
||
});
|
||
|
||
// Sort dropdown: options come from PromptListSort; sorting runs on the server
|
||
const sortSelect = document.getElementById("sortBy");
|
||
sortSelect.innerHTML = PromptListSort.SORT_OPTIONS
|
||
.map((o) => `<option value="${o.value}">${escapeHtml(o.label)}</option>`)
|
||
.join("");
|
||
sortSelect.value = PromptListSort.DEFAULT_SORT;
|
||
sortSelect.addEventListener("change", () => this.reloadPrompts());
|
||
}
|
||
|
||
bindModalEvents() {
|
||
// Settings modal
|
||
document.getElementById("saveSettings").addEventListener("click", () => this.saveSettings());
|
||
document.getElementById("cancelSettings").addEventListener("click", () => this.hideModal("settingsModal"));
|
||
|
||
// Bulk tag modal
|
||
document.getElementById("confirmBulkTag").addEventListener("click", () => this.confirmBulkTag());
|
||
document.getElementById("cancelBulkTag").addEventListener("click", () => this.hideModal("bulkTagModal"));
|
||
|
||
// Individual tag modal
|
||
document.getElementById("confirmIndividualTag").addEventListener("click", () => this.confirmIndividualTag());
|
||
document.getElementById("cancelIndividualTag").addEventListener("click", () => this.hideModal("individualTagModal"));
|
||
|
||
// Bulk category modal
|
||
document.getElementById("confirmBulkCategory").addEventListener("click", () => this.confirmBulkCategory());
|
||
document.getElementById("cancelBulkCategory").addEventListener("click", () => this.hideModal("bulkCategoryModal"));
|
||
|
||
|
||
// Scan modal
|
||
document.getElementById("startScan").addEventListener("click", () => this.startScan());
|
||
document.getElementById("cancelScan").addEventListener("click", () => this.dismissScanModal());
|
||
document.getElementById("scanPill").addEventListener("click", () => this.showScanModal());
|
||
document.getElementById("quickBackupBtn").addEventListener("click", () => this.quickBackup());
|
||
|
||
// Auto Tag modals
|
||
document.getElementById("startAutoTagBtn").addEventListener("click", () => this.startAutoTag());
|
||
document.getElementById("startReviewBtn").addEventListener("click", () => this.startReview());
|
||
document.getElementById("skipReviewBtn").addEventListener("click", () => this.skipReviewImage());
|
||
document.getElementById("applyReviewBtn").addEventListener("click", () => this.applyReviewTags());
|
||
document.getElementById("cancelAutoTagBtn").addEventListener("click", () => this.cancelAutoTag());
|
||
|
||
// Re-tag confirmation modal buttons
|
||
document.getElementById("retagSkipBtn").addEventListener("click", () => this.handleRetagChoice('skip'));
|
||
document.getElementById("retagSkipAllBtn").addEventListener("click", () => this.handleRetagChoice('skipAll'));
|
||
document.getElementById("retagConfirmBtn").addEventListener("click", () => this.handleRetagChoice('retag'));
|
||
|
||
// WD14 model selection toggle (prompt vs thresholds)
|
||
document.querySelectorAll('input[name="autoTagModel"]').forEach(radio => {
|
||
radio.addEventListener('change', (e) => {
|
||
const isWd14 = e.target.value.startsWith('wd14');
|
||
document.getElementById('promptSection').classList.toggle('hidden', isWd14);
|
||
document.getElementById('wd14ThresholdSection').classList.toggle('hidden', !isWd14);
|
||
});
|
||
});
|
||
|
||
// WD14 threshold slider value display
|
||
document.getElementById('wd14GeneralThreshold').addEventListener('input', (e) => {
|
||
document.getElementById('wd14GeneralThresholdValue').textContent = parseFloat(e.target.value).toFixed(2);
|
||
});
|
||
document.getElementById('wd14CharacterThreshold').addEventListener('input', (e) => {
|
||
document.getElementById('wd14CharacterThresholdValue').textContent = parseFloat(e.target.value).toFixed(2);
|
||
});
|
||
|
||
// Tags accordion toggle
|
||
document.getElementById("reviewTagsToggle").addEventListener("click", () => this.toggleReviewTags());
|
||
|
||
// Add Prompt modal
|
||
document.getElementById("saveNewPromptBtn").addEventListener("click", () => this.saveNewPrompt());
|
||
this.setupAddPromptModal();
|
||
|
||
// Close modals on backdrop click
|
||
document.querySelectorAll("[id$='Modal']").forEach((modal) => {
|
||
modal.addEventListener("click", (e) => {
|
||
if (e.target !== modal) return;
|
||
// The scan modal only leaves through its own buttons (Cancel / Hide): a click
|
||
// that merely focuses the window must not close or minimize a long job.
|
||
if (modal.id === "scanModal") return;
|
||
modal.classList.add("hidden");
|
||
modal.classList.remove("flex");
|
||
document.body.style.overflow = "";
|
||
});
|
||
});
|
||
}
|
||
|
||
async loadInitialData() {
|
||
try {
|
||
await Promise.all([
|
||
this.loadStatistics(),
|
||
this.loadCategories(),
|
||
this.loadSubfolders(),
|
||
this.loadTags(),
|
||
this.loadRecentPrompts(),
|
||
this.loadSettings(),
|
||
this.loadLibraryLocation(),
|
||
]);
|
||
} catch (error) {
|
||
this.showNotification("Failed to load initial data", "error");
|
||
console.error("Load error:", error);
|
||
}
|
||
}
|
||
|
||
async loadSettings() {
|
||
try {
|
||
const response = await ApiClient.fetchUrl("/prompt_manager/settings");
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
if (data.success && data.settings) {
|
||
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 });
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("Settings load error:", error);
|
||
}
|
||
}
|
||
|
||
/** A new lifetime once the old one has ended; a living page keeps its list guard. */
|
||
revivePageLifetime() {
|
||
if (this.pageLifetime.signal.aborted) this.startPageLifetime();
|
||
}
|
||
|
||
/** A fresh page lifetime, and the list guard bound to it. */
|
||
startPageLifetime() {
|
||
this.pageLifetime = new AbortController();
|
||
// Search and page loads share one guard: whichever list request is newest wins
|
||
this.listRequest = ApiClient.latestOnly({ lifetime: this.pageLifetime.signal });
|
||
}
|
||
|
||
/** #249 S6b: one line under the statistics saying where the library lives and why. */
|
||
async loadLibraryLocation() {
|
||
const { signal } = this.pageLifetime;
|
||
if (signal.aborted) return;
|
||
const line = document.getElementById("libraryLocation");
|
||
const reasons = {
|
||
default: "ComfyUI's user folder",
|
||
environment: "set by PROMPT_MANAGER_DATA_DIR",
|
||
config: "set in config.json",
|
||
node_folder: "the node folder, where an update or reinstall can delete it; move it from the workspace's System page",
|
||
};
|
||
try {
|
||
const response = await ApiClient.fetchUrl("/prompt_manager/system/library", { signal });
|
||
const data = response.ok ? await response.json() : null;
|
||
const location = data && data.location;
|
||
if (!line || !location || typeof location.path !== "string") return;
|
||
line.textContent = `Library: ${location.path} (${reasons[location.source] || location.source})`;
|
||
line.hidden = false;
|
||
} catch (error) {
|
||
if (!signal.aborted) console.error("Library location error:", error);
|
||
}
|
||
}
|
||
|
||
async loadStatistics() {
|
||
// A leaving page starts no read; one in flight is aborted with the page and not reported.
|
||
const { signal } = this.pageLifetime;
|
||
if (signal.aborted) return;
|
||
try {
|
||
const response = await ApiClient.fetchUrl("/prompt_manager/stats", { signal });
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
// Try different possible response structures
|
||
const stats = data.stats || data;
|
||
document.getElementById("totalPrompts").textContent = stats.total_prompts || data.total_prompts || 0;
|
||
document.getElementById("totalCategories").textContent = stats.total_categories || data.total_categories || 0;
|
||
document.getElementById("totalTags").textContent = stats.total_tags || data.total_tags || 0;
|
||
document.getElementById("avgRating").textContent =
|
||
(stats.avg_rating || data.avg_rating) ? (stats.avg_rating || data.avg_rating).toFixed(1) : "N/A";
|
||
} else {
|
||
console.error("Stats API returned success=false:", data);
|
||
}
|
||
} else {
|
||
console.error("Stats API HTTP error:", response.status, response.statusText);
|
||
}
|
||
} catch (error) {
|
||
if (signal.aborted) return;
|
||
console.error("Stats error:", error);
|
||
// Set some default values if API fails
|
||
document.getElementById("totalPrompts").textContent = this.prompts.length || 0;
|
||
document.getElementById("totalCategories").textContent = this.categories.length || 0;
|
||
document.getElementById("totalTags").textContent = this.tags.length || 0;
|
||
document.getElementById("avgRating").textContent = "N/A";
|
||
}
|
||
}
|
||
|
||
async loadCategories() {
|
||
try {
|
||
const response = await ApiClient.fetchUrl("/prompt_manager/categories");
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
this.categories = data.categories;
|
||
this.populateCategoryDropdown();
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("Categories error:", error);
|
||
}
|
||
}
|
||
|
||
async loadTags() {
|
||
try {
|
||
const response = await ApiClient.fetchUrl("/prompt_manager/tags");
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
this.tags = data.tags;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("Tags error:", error);
|
||
}
|
||
}
|
||
|
||
populateCategoryDropdown() {
|
||
const select = document.getElementById("searchCategory");
|
||
select.innerHTML = '<option value="">All Categories</option>';
|
||
this.categories.forEach((category) => {
|
||
const option = document.createElement("option");
|
||
option.value = category;
|
||
option.textContent = category;
|
||
select.appendChild(option);
|
||
});
|
||
}
|
||
|
||
async loadSubfolders() {
|
||
try {
|
||
const response = await ApiClient.fetchUrl("/prompt_manager/subfolders?include_ancestors=true");
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
this.subfolders = data.subfolders;
|
||
this.populateFolderDropdown();
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("Subfolders error:", error);
|
||
}
|
||
}
|
||
|
||
populateFolderDropdown() {
|
||
const select = document.getElementById("searchFolder");
|
||
const current = select.value;
|
||
select.textContent = '';
|
||
const defaultOpt = document.createElement("option");
|
||
defaultOpt.value = "";
|
||
defaultOpt.textContent = "All Folders";
|
||
select.appendChild(defaultOpt);
|
||
this.subfolders.forEach((folder) => {
|
||
const option = document.createElement("option");
|
||
option.value = folder;
|
||
const depth = folder.split("/").length - 1;
|
||
const indent = "\u00A0\u00A0".repeat(depth);
|
||
const label = folder.split("/").pop();
|
||
option.textContent = indent + label;
|
||
select.appendChild(option);
|
||
});
|
||
select.value = current;
|
||
}
|
||
|
||
/**
|
||
* Load one page of recent prompts. The page is committed to this.pagination only
|
||
* after the server answered; a newer list request supersedes one still in flight.
|
||
*/
|
||
async loadRecentPrompts(page = 1, { append = false } = {}) {
|
||
this.listMode = "recent";
|
||
const url = PromptListSort.buildRecentUrl({
|
||
page,
|
||
limit: this.pagination.limit,
|
||
sort: this.currentSort(),
|
||
});
|
||
try {
|
||
const data = await this.listRequest((signal) => this.api.get(url, { signal }));
|
||
if (data === ApiClient.STALE) return; // a newer list request owns the loading state
|
||
|
||
const next = ListState.nextPageState(
|
||
{ page: this.pagination.currentPage, limit: this.pagination.limit, total: this.pagination.total },
|
||
{ page: data.pagination.page, total: data.pagination.total },
|
||
);
|
||
this.prompts = append ? this.prompts.concat(data.results) : data.results;
|
||
this.pagination = { ...this.pagination, currentPage: next.page, total: next.total, totalPages: next.totalPages };
|
||
this.renderPrompts();
|
||
this.updatePaginationControls();
|
||
document.getElementById("resultsTitle").textContent = "Recent Prompts";
|
||
this.maybeLoadMorePrompts(); // a short page may not fill the list
|
||
} catch (error) {
|
||
console.error("Recent prompts error:", error);
|
||
this.showNotification("Failed to load prompts", "error");
|
||
}
|
||
this.showListLoaded();
|
||
this.updatePaginationVisibility();
|
||
}
|
||
|
||
showListLoaded() {
|
||
document.getElementById("loadingState").classList.add("hidden");
|
||
document.getElementById("resultsList").classList.remove("hidden");
|
||
}
|
||
|
||
updateLocalStats() {
|
||
// Only update local stats if we haven't loaded proper statistics yet
|
||
// This prevents overwriting the correct total counts from the API
|
||
const currentTotal = document.getElementById("totalPrompts").textContent;
|
||
|
||
// Only update if the current total is still the default "-"
|
||
if (currentTotal === "-") {
|
||
// Show local counts as fallback only when API hasn't loaded yet
|
||
document.getElementById("totalPrompts").textContent = this.prompts.length;
|
||
|
||
const uniqueCategories = new Set(this.prompts.map(p => p.category).filter(Boolean));
|
||
document.getElementById("totalCategories").textContent = uniqueCategories.size;
|
||
|
||
const allTags = this.prompts.flatMap(p => Array.isArray(p.tags) ? p.tags : []);
|
||
const uniqueTags = new Set(allTags.filter(Boolean));
|
||
document.getElementById("totalTags").textContent = uniqueTags.size;
|
||
|
||
const ratings = this.prompts.map(p => p.rating).filter(r => r && r > 0);
|
||
if (ratings.length > 0) {
|
||
const avgRating = ratings.reduce((a, b) => a + b, 0) / ratings.length;
|
||
document.getElementById("avgRating").textContent = avgRating.toFixed(1);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Tag autocomplete ─────────────────────────────────────────────
|
||
scheduleTagSuggestions(delay = 150) {
|
||
clearTimeout(this.tagSuggest.timer);
|
||
this.tagSuggest.timer = setTimeout(() => this.loadTagSuggestions(), delay);
|
||
}
|
||
|
||
async loadTagSuggestions() {
|
||
const input = document.getElementById("searchTags");
|
||
const url = TagAutocomplete.suggestUrl(input.value);
|
||
try {
|
||
const data = await this.tagSuggest.request((signal) => this.api.get(url, { signal }));
|
||
if (data === ApiClient.STALE) return;
|
||
this.renderTagSuggestions(data.suggestions || []);
|
||
} catch (_) {
|
||
this.hideTagSuggestions();
|
||
}
|
||
}
|
||
|
||
renderTagSuggestions(items) {
|
||
const box = document.getElementById("searchTagSuggestions");
|
||
this.tagSuggest.items = items;
|
||
this.tagSuggest.index = -1;
|
||
const focused = document.activeElement === document.getElementById("searchTags");
|
||
if (!items.length || !focused) {
|
||
box.classList.add("hidden");
|
||
return;
|
||
}
|
||
box.innerHTML = items.map((s, i) => `
|
||
<div class="flex items-center justify-between px-3 py-1.5 cursor-pointer text-[13px] text-pm hover:bg-pm-hover"
|
||
role="option" data-index="${i}" data-action="pick-tag-suggestion" data-tag="${escapeHtml(s.name)}">
|
||
<span class="truncate">${escapeHtml(s.name)}</span>
|
||
<span class="text-xs text-pm-muted ml-3">${escapeHtml(String(s.count))}</span>
|
||
</div>`).join("");
|
||
box.classList.remove("hidden");
|
||
}
|
||
|
||
hideTagSuggestions() {
|
||
document.getElementById("searchTagSuggestions").classList.add("hidden");
|
||
this.tagSuggest.index = -1;
|
||
}
|
||
|
||
highlightTagSuggestion() {
|
||
const box = document.getElementById("searchTagSuggestions");
|
||
box.querySelectorAll("[data-index]").forEach((el) => {
|
||
const selected = Number(el.dataset.index) === this.tagSuggest.index;
|
||
el.classList.toggle("bg-pm-hover", selected);
|
||
if (selected) el.scrollIntoView({ block: "nearest" });
|
||
});
|
||
}
|
||
|
||
handleTagSuggestKey(e) {
|
||
const open = !document.getElementById("searchTagSuggestions").classList.contains("hidden");
|
||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||
e.preventDefault();
|
||
if (!open) {
|
||
this.scheduleTagSuggestions(0);
|
||
return;
|
||
}
|
||
const delta = e.key === "ArrowDown" ? 1 : -1;
|
||
this.tagSuggest.index = TagAutocomplete.moveSelection(this.tagSuggest.index, delta, this.tagSuggest.items.length);
|
||
this.highlightTagSuggestion();
|
||
return;
|
||
}
|
||
if ((e.key === "Enter" || e.key === "Tab") && open && this.tagSuggest.index >= 0) {
|
||
e.preventDefault();
|
||
this.pickTagSuggestion(this.tagSuggest.items[this.tagSuggest.index].name);
|
||
return;
|
||
}
|
||
if (e.key === "Enter") {
|
||
this.hideTagSuggestions();
|
||
this.search();
|
||
return;
|
||
}
|
||
if (e.key === "Escape") this.hideTagSuggestions();
|
||
}
|
||
|
||
pickTagSuggestion(tag) {
|
||
const input = document.getElementById("searchTags");
|
||
input.value = TagAutocomplete.applySuggestion(input.value, tag);
|
||
input.focus();
|
||
this.scheduleTagSuggestions(0); // offer the next tag, narrowed by this one
|
||
}
|
||
|
||
async search(page = 1, { append = false } = {}) {
|
||
const searchText = document.getElementById("searchText").value;
|
||
const category = document.getElementById("searchCategory").value;
|
||
const tags = document.getElementById("searchTags").value;
|
||
|
||
if (!append) {
|
||
document.getElementById("loadingState").classList.remove("hidden");
|
||
document.getElementById("resultsList").classList.add("hidden");
|
||
}
|
||
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (searchText) params.append("text", searchText);
|
||
if (category) params.append("category", category);
|
||
if (tags) params.append("tags", tags);
|
||
const folder = document.getElementById("searchFolder").value;
|
||
if (folder) params.append("folder", folder);
|
||
const limit = this.pagination.limit;
|
||
params.append("limit", String(limit));
|
||
params.append("offset", String((page - 1) * limit));
|
||
params.append("page", String(page));
|
||
|
||
this.listMode = "search";
|
||
const url = `/prompt_manager/search?${PromptListSort.withSort(params, this.currentSort())}`;
|
||
const data = await this.listRequest((signal) => this.api.get(url, { signal }));
|
||
if (data === ApiClient.STALE) return; // a newer list request owns the loading state
|
||
|
||
const reported = data.pagination || {};
|
||
const next = ListState.nextPageState(
|
||
{ page: this.pagination.currentPage, limit, total: this.pagination.total },
|
||
{ page: reported.page ?? page, total: reported.total ?? data.results.length },
|
||
);
|
||
this.prompts = append ? this.prompts.concat(data.results) : data.results;
|
||
this.pagination = { ...this.pagination, currentPage: next.page, total: next.total, totalPages: next.totalPages };
|
||
this.renderPrompts();
|
||
this.updatePaginationControls();
|
||
document.getElementById("resultsTitle").textContent = "Search Results";
|
||
this.maybeLoadMorePrompts();
|
||
} catch (error) {
|
||
this.showNotification("Search failed", "error");
|
||
console.error("Search error:", error);
|
||
}
|
||
this.showListLoaded();
|
||
this.updatePaginationVisibility();
|
||
}
|
||
|
||
currentSort() {
|
||
return PromptListSort.normalizeSort(document.getElementById("sortBy")?.value);
|
||
}
|
||
|
||
/** Re-fetch the list currently shown from its first page, e.g. after the sort changes. */
|
||
reloadPrompts() {
|
||
return this.listMode === "search" ? this.search() : this.loadRecentPrompts(1);
|
||
}
|
||
|
||
/** Re-fetch the page currently shown, e.g. after a prompt was edited, tagged or deleted. */
|
||
refreshList() {
|
||
// With infinite scroll the list holds several pages; re-fetch from the top.
|
||
const page = this.infiniteScroll ? 1 : this.pagination.currentPage;
|
||
return this.listMode === "search" ? this.search(page) : this.loadRecentPrompts(page);
|
||
}
|
||
|
||
// ── Infinite scrolling ───────────────────────────────────────────
|
||
setInfiniteScroll(enabled, { session }) {
|
||
if (session) this.infiniteScrollChosen = true;
|
||
const changed = this.infiniteScroll !== !!enabled;
|
||
this.infiniteScroll = !!enabled;
|
||
document.getElementById("infiniteScrollSession").checked = this.infiniteScroll;
|
||
this.updatePaginationVisibility();
|
||
if (changed && this.prompts.length) this.reloadPrompts();
|
||
}
|
||
|
||
updatePaginationVisibility() {
|
||
document.getElementById("paginationControls").classList.toggle("hidden", this.infiniteScroll);
|
||
}
|
||
|
||
maybeLoadMorePrompts() {
|
||
const el = document.getElementById("resultsList");
|
||
const wanted = ListState.shouldLoadMore({
|
||
infiniteScroll: this.infiniteScroll,
|
||
loading: this.loadingMore,
|
||
page: this.pagination.currentPage,
|
||
limit: this.pagination.limit,
|
||
total: this.pagination.total,
|
||
scrollTop: el.scrollTop,
|
||
clientHeight: el.clientHeight,
|
||
scrollHeight: el.scrollHeight,
|
||
});
|
||
if (wanted) this.loadMorePrompts();
|
||
}
|
||
|
||
async loadMorePrompts() {
|
||
if (this.loadingMore) return;
|
||
this.loadingMore = true;
|
||
try {
|
||
const page = this.pagination.currentPage + 1;
|
||
await (this.listMode === "search" ? this.search(page, { append: true }) : this.loadRecentPrompts(page, { append: true }));
|
||
} finally {
|
||
this.loadingMore = false;
|
||
}
|
||
}
|
||
|
||
handleActionClick(e) {
|
||
const target = e.target.closest("[data-action]");
|
||
if (target) DataActions.dispatch(target.dataset, this.actionHandlers, target);
|
||
}
|
||
|
||
/** Swap a thumbnail for its full-size image once, when the thumbnail fails to load. */
|
||
applyImageFallback(img) {
|
||
if (!img || img.tagName !== "IMG" || !img.dataset.fallbackSrc) return;
|
||
const fallback = img.dataset.fallbackSrc;
|
||
delete img.dataset.fallbackSrc;
|
||
img.src = fallback;
|
||
}
|
||
|
||
renderPrompts() {
|
||
const container = document.getElementById("resultsList");
|
||
const count = document.getElementById("resultsCount");
|
||
|
||
count.textContent = `${this.prompts.length} prompts`;
|
||
|
||
if (this.prompts.length === 0) {
|
||
container.innerHTML = `
|
||
<div class="text-center py-6">
|
||
<div class="w-16 h-16 bg-pm-surface rounded-full flex items-center justify-center mx-auto mb-4">
|
||
<span class="text-lg">📭</span>
|
||
</div>
|
||
<h3 class="text-sm font-semibold text-pm-secondary mb-2">No prompts found</h3>
|
||
<p class="text-pm-muted">Try adjusting your search criteria or create some prompts in ComfyUI</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
// Already in the selected order: the server sorts across all pages
|
||
container.innerHTML = this.prompts.map((prompt) => this.renderPromptItem(prompt)).join("");
|
||
this.selectedPrompts.clear();
|
||
this.updateBulkActionButtons();
|
||
|
||
// Add hover behavior to all star ratings
|
||
this.prompts.forEach(prompt => {
|
||
this.addStarHoverBehavior(prompt.id);
|
||
});
|
||
|
||
// Load film strips for each prompt (async, non-blocking)
|
||
this.loadAllFilmStrips(this.prompts);
|
||
}
|
||
|
||
async loadAllFilmStrips(prompts) {
|
||
for (const prompt of prompts) {
|
||
const container = document.getElementById(`filmStrip-${prompt.id}`);
|
||
if (!container) continue;
|
||
|
||
// Use pre-loaded images from API response if available
|
||
if (prompt.images && prompt.images.length > 0) {
|
||
container.innerHTML = this.createFilmStrip(prompt.images, prompt.id); // trusted internal HTML
|
||
} else if (prompt.image_count > 0) {
|
||
// Images exist but weren't pre-loaded — fetch individually
|
||
this.loadFilmStripForPrompt(prompt.id);
|
||
}
|
||
}
|
||
}
|
||
|
||
async loadFilmStripForPrompt(promptId) {
|
||
const container = document.getElementById(`filmStrip-${promptId}`);
|
||
if (!container) return;
|
||
|
||
try {
|
||
const images = await this.loadFilmStripImages(promptId);
|
||
container.innerHTML = this.createFilmStrip(images, promptId); // trusted internal HTML
|
||
} catch (error) {
|
||
console.error(`Error loading filmstrip for prompt ${promptId}:`, error);
|
||
container.innerHTML = `<div class="film-strip-empty">Failed to load images</div>`; // static HTML
|
||
}
|
||
}
|
||
|
||
renderPromptItem(prompt) {
|
||
const tags = Array.isArray(prompt.tags) ? prompt.tags : [];
|
||
const category = prompt.category || "No category";
|
||
const rating = prompt.rating || 0;
|
||
const created = new Date(prompt.created_at).toLocaleDateString();
|
||
const runLabel = PromptListSort.formatRunCount(prompt.run_count);
|
||
const lastUsedTitle = prompt.last_used_at
|
||
? `Last used ${new Date(prompt.last_used_at).toLocaleString()}`
|
||
: "";
|
||
|
||
return `
|
||
<div class="bg-pm-surface rounded-pm-md border border-pm hover:border-pm transition-all duration-200" data-id="${prompt.id}">
|
||
<div class="p-4">
|
||
<div class="flex items-start space-x-4">
|
||
<input type="checkbox" class="prompt-checkbox w-5 h-5 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent mt-1" data-id="${prompt.id}">
|
||
|
||
<div class="flex-1 min-w-0">
|
||
<div class="bg-pm-surface rounded-pm-sm p-4 mb-4 relative group">
|
||
<div class="prompt-text text-pm leading-relaxed whitespace-pre-wrap" data-id="${prompt.id}">${escapeHtml(prompt.text)}</div>
|
||
<button class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-pm-surface hover:bg-pm-hover text-pm-secondary hover:text-pm p-2 rounded-pm-sm text-sm" data-action="copy-prompt" data-prompt-id="${prompt.id}">
|
||
📋 Copy
|
||
</button>
|
||
</div>
|
||
|
||
<div class="flex flex-wrap items-center gap-4 text-sm text-pm-secondary mb-3">
|
||
<div class="flex items-center space-x-1">
|
||
<span>📁</span>
|
||
<span class="category-text" data-id="${prompt.id}">${escapeHtml(category)}</span>
|
||
</div>
|
||
<div class="flex items-center space-x-1">
|
||
<span>📅</span>
|
||
<span>${created}</span>
|
||
</div>
|
||
${runLabel ? `<div class="flex items-center space-x-1" title="${escapeHtml(lastUsedTitle)}">
|
||
<span>🔁</span>
|
||
<span>${escapeHtml(runLabel)}</span>
|
||
</div>` : ""}
|
||
<div class="flex items-center space-x-1">
|
||
<div class="rating flex space-x-1" data-id="${escapeHtml(prompt.id)}" data-rating="${escapeHtml(rating)}">
|
||
${this.renderStars(rating, prompt.id)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tags-accordion" data-prompt-id="${prompt.id}">
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
${tags.slice(0, 10).map(tag => `
|
||
<span class="inline-flex items-center space-x-1 bg-pm-input text-pm px-3 py-1 rounded-full text-sm border border-pm">
|
||
<span>${escapeHtml(tag)}</span>
|
||
<button class="remove-tag-btn text-pm-secondary hover:text-pm ml-1" data-action="remove-tag" data-prompt-id="${prompt.id}" data-tag="${escapeHtml(tag)}">×</button>
|
||
</span>
|
||
`).join("")}
|
||
<button class="inline-flex items-center space-x-1 bg-pm-input hover:bg-pm-hover text-pm-secondary px-3 py-1 rounded-full text-sm transition-colors" data-action="add-tags" data-prompt-id="${prompt.id}">
|
||
<span>+</span>
|
||
<span>Add Tags</span>
|
||
</button>
|
||
</div>
|
||
${tags.length > 10 ? `
|
||
<div class="tags-hidden hidden mt-2">
|
||
<div class="flex flex-wrap items-center gap-2 max-h-[180px] overflow-y-auto p-2 bg-pm-surface rounded-pm-sm">
|
||
${tags.slice(10).map(tag => `
|
||
<span class="inline-flex items-center space-x-1 bg-pm-input text-pm px-3 py-1 rounded-full text-sm border border-pm">
|
||
<span>${escapeHtml(tag)}</span>
|
||
<button class="remove-tag-btn text-pm-secondary hover:text-pm ml-1" data-action="remove-tag" data-prompt-id="${prompt.id}" data-tag="${escapeHtml(tag)}">×</button>
|
||
</span>
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
<button class="tags-toggle-btn mt-2 text-sm text-pm-accent hover:text-pm-accent transition-colors flex items-center gap-1" data-action="toggle-tags" data-prompt-id="${prompt.id}">
|
||
<span class="toggle-icon">▼</span>
|
||
<span class="toggle-text">Show ${tags.length - 10} more tags</span>
|
||
</button>
|
||
` : ''}
|
||
</div>
|
||
|
||
<!-- Film Strip -->
|
||
<div class="film-strip-container mt-3 pt-3 border-t border-pm-subtle" id="filmStrip-${prompt.id}">
|
||
<div class="film-strip-empty">Loading images...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex flex-col space-y-2">
|
||
<button class="px-4 py-2 bg-pm-accent hover:bg-pm-accent-hover text-pm text-sm font-medium rounded-pm-sm transition-colors" data-action="gallery" data-prompt-id="${prompt.id}">
|
||
🖼️ Gallery
|
||
</button>
|
||
<button class="px-4 py-2 bg-pm-accent hover:bg-pm-accent-hover text-pm text-sm font-medium rounded-pm-sm transition-colors" data-action="edit" data-prompt-id="${prompt.id}">
|
||
✏️ Edit
|
||
</button>
|
||
<button class="px-4 py-2 bg-pm-error hover:bg-pm-error text-pm text-sm font-medium rounded-pm-sm transition-colors" data-action="delete" data-prompt-id="${prompt.id}">
|
||
🗑️ Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
renderStars(rating, promptId) {
|
||
return Array.from({ length: 5 }, (_, i) => {
|
||
const starNum = i + 1;
|
||
const isActive = starNum <= rating;
|
||
const starIcon = isActive ? '⭐' : '☆'; // filled star vs outline star
|
||
const color = isActive ? 'var(--pm-warning)' : 'var(--pm-text-secondary)'; // yellow-400 : gray-400
|
||
return `<button class="star-btn" data-star="${starNum}" data-prompt-id="${promptId}" style="font-size: 1.125rem; color: ${color}; background: none; border: none; cursor: pointer; transition: all 0.2s; width: 1.125rem; height: 1.125rem; display: inline-flex; align-items: center; justify-content: center; padding: 0; line-height: 1;" onclick="window.admin.setRating(${promptId}, ${starNum})">${starIcon}</button>`;
|
||
}).join("");
|
||
}
|
||
|
||
addStarHoverBehavior(promptId) {
|
||
const starButtons = document.querySelectorAll(`[data-prompt-id="${promptId}"].star-btn`);
|
||
const ratingElement = document.querySelector(`[data-id="${promptId}"][data-rating]`);
|
||
const currentRating = parseInt(ratingElement?.dataset.rating || 0);
|
||
|
||
starButtons.forEach((starBtn, index) => {
|
||
const starNum = index + 1;
|
||
|
||
starBtn.addEventListener('mouseenter', () => {
|
||
// Light up all stars up to this one on hover
|
||
starButtons.forEach((btn, i) => {
|
||
if (i < starNum) {
|
||
btn.textContent = '⭐';
|
||
btn.style.color = 'var(--pm-warning)'; // bright yellow on hover
|
||
} else {
|
||
btn.textContent = '☆';
|
||
btn.style.color = 'var(--pm-text-secondary)'; // gray
|
||
}
|
||
});
|
||
});
|
||
|
||
starBtn.addEventListener('mouseleave', () => {
|
||
// Reset to current rating
|
||
starButtons.forEach((btn, i) => {
|
||
if (i < currentRating) {
|
||
btn.textContent = '⭐';
|
||
btn.style.color = 'var(--pm-warning)'; // yellow
|
||
} else {
|
||
btn.textContent = '☆';
|
||
btn.style.color = 'var(--pm-text-secondary)'; // gray
|
||
}
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
showNotification(message, type = "info") {
|
||
const notification = document.createElement("div");
|
||
|
||
// Check if ViewerJS is open and use higher z-index
|
||
const viewerContainer = document.querySelector('.viewer-container');
|
||
const isViewerOpen = viewerContainer && viewerContainer.style.display !== 'none';
|
||
const zIndex = isViewerOpen ? 'z-[50000]' : 'z-50';
|
||
|
||
notification.className = `fixed top-4 right-4 px-4 py-2 rounded-pm-sm shadow-pm text-sm ${zIndex} transition-all duration-300 transform translate-x-full`;
|
||
|
||
const colors = {
|
||
success: "bg-pm-success text-pm",
|
||
error: "bg-pm-error text-pm",
|
||
warning: "bg-pm-warning text-pm",
|
||
info: "bg-pm-accent text-pm"
|
||
};
|
||
|
||
notification.className += ` ${colors[type] || colors.info}`;
|
||
notification.textContent = message;
|
||
|
||
document.body.appendChild(notification);
|
||
|
||
setTimeout(() => notification.classList.remove("translate-x-full"), 100);
|
||
setTimeout(() => {
|
||
notification.classList.add("translate-x-full");
|
||
setTimeout(() => {
|
||
if (notification.parentNode) {
|
||
document.body.removeChild(notification);
|
||
}
|
||
}, 300);
|
||
}, 3000);
|
||
}
|
||
|
||
showModal(modalId) {
|
||
document.getElementById(modalId).classList.remove("hidden");
|
||
document.getElementById(modalId).classList.add("flex");
|
||
document.body.style.overflow = "hidden";
|
||
}
|
||
|
||
hideModal(modalId) {
|
||
document.getElementById(modalId).classList.add("hidden");
|
||
document.getElementById(modalId).classList.remove("flex");
|
||
document.body.style.overflow = "";
|
||
}
|
||
|
||
checkUpdateNotice() {
|
||
const NOTICE_VERSION = "3.2.0";
|
||
const dismissed = localStorage.getItem("pm_update_notice_dismissed");
|
||
if (dismissed === NOTICE_VERSION) return;
|
||
|
||
this.showModal("updateNoticeModal");
|
||
|
||
document.getElementById("updateNoticeDismiss").addEventListener("click", () => {
|
||
if (document.getElementById("dismissUpdateNotice").checked) {
|
||
localStorage.setItem("pm_update_notice_dismissed", NOTICE_VERSION);
|
||
}
|
||
this.hideModal("updateNoticeModal");
|
||
});
|
||
|
||
document.getElementById("updateNoticeScan").addEventListener("click", () => {
|
||
localStorage.setItem("pm_update_notice_dismissed", NOTICE_VERSION);
|
||
this.hideModal("updateNoticeModal");
|
||
this.showScanModal();
|
||
});
|
||
}
|
||
|
||
/** Page options: the prompt list's saved Infinite Scrolling default (scan folders and the rest are in System). */
|
||
showSettingsModal() {
|
||
document.getElementById("infiniteScrollDefault").checked = this.settings.infiniteScroll;
|
||
this.showModal("settingsModal");
|
||
}
|
||
|
||
/** Saves Page options: only `infinite_scroll`, so a stale page never overwrites what System saved (#330 S13-9). */
|
||
async saveSettings() {
|
||
const infiniteScroll = document.getElementById("infiniteScrollDefault").checked;
|
||
|
||
try {
|
||
const response = await ApiClient.fetchUrl("/prompt_manager/settings", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ infinite_scroll: infiniteScroll }),
|
||
});
|
||
|
||
if (response.ok) {
|
||
this.settings.infiniteScroll = infiniteScroll;
|
||
this.setInfiniteScroll(infiniteScroll, { session: false });
|
||
this.showNotification("Page options saved", "success");
|
||
this.hideModal("settingsModal");
|
||
} else {
|
||
// A refusal names its reason (a corrupt config.json, a changed file): show it.
|
||
const data = await response.json().catch(() => ({}));
|
||
throw new Error(data.error ? `Failed to save settings: ${data.error}` : "Failed to save settings");
|
||
}
|
||
} catch (error) {
|
||
const reason = error.message.startsWith("Failed to save settings:") ? error.message : "Failed to save settings";
|
||
this.showNotification(reason, "error");
|
||
}
|
||
}
|
||
|
||
toggleSelectAll(checked) {
|
||
document.querySelectorAll(".prompt-checkbox").forEach((checkbox) => {
|
||
checkbox.checked = checked;
|
||
if (checked) {
|
||
this.selectedPrompts.add(parseInt(checkbox.dataset.id));
|
||
} else {
|
||
this.selectedPrompts.clear();
|
||
}
|
||
});
|
||
this.updateBulkActionButtons();
|
||
}
|
||
|
||
updateBulkActionButtons() {
|
||
const hasSelected = this.selectedPrompts.size > 0;
|
||
document.getElementById("bulkDeleteBtn").disabled = !hasSelected;
|
||
document.getElementById("bulkTagBtn").disabled = !hasSelected;
|
||
document.getElementById("bulkCategoryBtn").disabled = !hasSelected;
|
||
}
|
||
|
||
async setRating(promptId, rating) {
|
||
|
||
// Update UI immediately for better UX
|
||
const ratingElement = document.querySelector(`[data-id="${promptId}"][data-rating]`);
|
||
if (ratingElement) {
|
||
ratingElement.dataset.rating = rating;
|
||
ratingElement.innerHTML = this.renderStars(rating, promptId);
|
||
// Add hover behavior to new stars
|
||
this.addStarHoverBehavior(promptId);
|
||
}
|
||
|
||
try {
|
||
const response = await ApiClient.fetchUrl(`/prompt_manager/prompts/${promptId}/rating`, {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ rating }),
|
||
});
|
||
|
||
if (response.ok) {
|
||
this.showNotification("Rating updated", "success");
|
||
|
||
// Update the prompt data in memory
|
||
const promptIndex = this.prompts.findIndex(p => p.id === promptId);
|
||
if (promptIndex !== -1) {
|
||
this.prompts[promptIndex].rating = rating;
|
||
}
|
||
|
||
// Refresh stats
|
||
setTimeout(() => {
|
||
this.loadStatistics();
|
||
}, 500);
|
||
} else {
|
||
// Revert UI change on API failure
|
||
const originalPrompt = this.prompts.find(p => p.id === promptId);
|
||
const originalRating = originalPrompt ? originalPrompt.rating : 0;
|
||
if (ratingElement) {
|
||
ratingElement.dataset.rating = originalRating;
|
||
ratingElement.innerHTML = this.renderStars(originalRating, promptId);
|
||
}
|
||
this.showNotification("Failed to update rating", "error");
|
||
}
|
||
} catch (error) {
|
||
console.error("Rating update error:", error);
|
||
// Revert UI change on error
|
||
const originalPrompt = this.prompts.find(p => p.id === promptId);
|
||
const originalRating = originalPrompt ? originalPrompt.rating : 0;
|
||
if (ratingElement) {
|
||
ratingElement.dataset.rating = originalRating;
|
||
ratingElement.innerHTML = this.renderStars(originalRating, promptId);
|
||
}
|
||
this.showNotification("Failed to update rating", "error");
|
||
}
|
||
}
|
||
|
||
addTag(promptId) {
|
||
// Store the prompt ID for later use
|
||
this.currentTagPromptId = promptId;
|
||
// Clear the input and show the modal
|
||
document.getElementById("individualTagInput").value = "";
|
||
this.showModal("individualTagModal");
|
||
}
|
||
|
||
async confirmIndividualTag() {
|
||
const tags = document.getElementById("individualTagInput").value.split(",")
|
||
.map((tag) => tag.trim()).filter((tag) => tag);
|
||
|
||
if (tags.length === 0) return;
|
||
|
||
try {
|
||
const response = await ApiClient.fetchUrl("/prompt_manager/prompts/tags", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
prompt_id: this.currentTagPromptId,
|
||
tags: tags,
|
||
}),
|
||
});
|
||
|
||
if (response.ok) {
|
||
this.showNotification(`Tags added to prompt`, "success");
|
||
this.hideModal("individualTagModal");
|
||
// Refresh both stats and results
|
||
await this.loadStatistics();
|
||
this.refreshList();
|
||
}
|
||
} catch (error) {
|
||
this.showNotification("Failed to add tags", "error");
|
||
}
|
||
}
|
||
|
||
async removeTag(promptId, tag) {
|
||
try {
|
||
const response = await ApiClient.fetchUrl(`/prompt_manager/prompts/${promptId}/tags`, {
|
||
method: "DELETE",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ tag }),
|
||
});
|
||
|
||
if (response.ok) {
|
||
this.showNotification("Tag removed", "success");
|
||
// Refresh both stats and results
|
||
await this.loadStatistics();
|
||
this.refreshList();
|
||
}
|
||
} catch (error) {
|
||
this.showNotification("Failed to remove tag", "error");
|
||
}
|
||
}
|
||
|
||
editPrompt(promptId) {
|
||
const promptElement = document.querySelector(`[data-id="${promptId}"].prompt-text`);
|
||
if (!promptElement) return;
|
||
if (this.activeEdit) this.finishEdit(false);
|
||
|
||
this.activeEdit = { promptId, element: promptElement, originalText: promptElement.textContent };
|
||
promptElement.contentEditable = true;
|
||
promptElement.focus();
|
||
promptElement.classList.add("bg-pm-surface", "border", "border-pm-accent", "rounded-pm-sm", "p-3");
|
||
}
|
||
|
||
handleEditKeydown(e) {
|
||
if (!this.activeEdit || e.target !== this.activeEdit.element) return;
|
||
if (e.key === "Enter" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
this.finishEdit(true);
|
||
} else if (e.key === "Escape") {
|
||
this.finishEdit(false);
|
||
}
|
||
}
|
||
|
||
handleEditFocusOut(e) {
|
||
if (this.activeEdit && e.target === this.activeEdit.element) this.finishEdit(true);
|
||
}
|
||
|
||
/**
|
||
* End the in-place edit exactly once. The edit state is cleared before the element
|
||
* changes, so the focusout caused by contentEditable=false cannot save a second time.
|
||
*/
|
||
async finishEdit(save) {
|
||
const edit = this.activeEdit;
|
||
if (!edit) return;
|
||
this.activeEdit = null;
|
||
|
||
const { element, originalText, promptId } = edit;
|
||
const newText = element.textContent.trim();
|
||
element.contentEditable = false;
|
||
element.classList.remove("bg-pm-surface", "border", "border-pm-accent", "rounded-pm-sm", "p-3");
|
||
|
||
if (!save || !ListState.shouldSaveEdit(originalText, newText)) {
|
||
element.textContent = originalText;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await this.api.put(`/prompt_manager/prompts/${promptId}`, { text: newText });
|
||
this.showNotification("Prompt updated", "success");
|
||
await this.loadStatistics();
|
||
this.refreshList();
|
||
} catch (error) {
|
||
console.error("Update failed:", error);
|
||
this.showNotification("Failed to update prompt", "error");
|
||
element.textContent = originalText;
|
||
}
|
||
}
|
||
|
||
async deletePrompt(promptId) {
|
||
if (!confirm("Are you sure you want to delete this prompt?")) return;
|
||
|
||
try {
|
||
await this.api.del(`/prompt_manager/delete/${promptId}`);
|
||
this.showNotification("Prompt deleted", "success");
|
||
await this.loadStatistics();
|
||
this.refreshList();
|
||
} catch (error) {
|
||
console.error("Delete failed:", error);
|
||
this.showNotification("Failed to delete prompt", "error");
|
||
}
|
||
}
|
||
|
||
showBulkTagModal() {
|
||
this.showModal("bulkTagModal");
|
||
}
|
||
|
||
showBulkCategoryModal() {
|
||
this.showModal("bulkCategoryModal");
|
||
}
|
||
|
||
async confirmBulkTag() {
|
||
const tags = document.getElementById("bulkTagInput").value.split(",")
|
||
.map((tag) => tag.trim()).filter((tag) => tag);
|
||
|
||
if (tags.length === 0) return;
|
||
|
||
try {
|
||
const response = await ApiClient.fetchUrl("/prompt_manager/bulk/tags", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
prompt_ids: Array.from(this.selectedPrompts),
|
||
tags,
|
||
}),
|
||
});
|
||
|
||
if (response.ok) {
|
||
this.showNotification(`Tags added to ${this.selectedPrompts.size} prompts`, "success");
|
||
this.hideModal("bulkTagModal");
|
||
// Refresh both stats and results
|
||
await this.loadStatistics();
|
||
this.refreshList();
|
||
}
|
||
} catch (error) {
|
||
this.showNotification("Failed to add tags", "error");
|
||
}
|
||
}
|
||
|
||
async confirmBulkCategory() {
|
||
const category = document.getElementById("bulkCategoryInput").value.trim();
|
||
if (!category) return;
|
||
|
||
try {
|
||
const response = await ApiClient.fetchUrl("/prompt_manager/bulk/category", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
prompt_ids: Array.from(this.selectedPrompts),
|
||
category,
|
||
}),
|
||
});
|
||
|
||
if (response.ok) {
|
||
this.showNotification(`Category set for ${this.selectedPrompts.size} prompts`, "success");
|
||
this.hideModal("bulkCategoryModal");
|
||
// Refresh both stats and results
|
||
await this.loadStatistics();
|
||
this.refreshList();
|
||
}
|
||
} catch (error) {
|
||
this.showNotification("Failed to set category", "error");
|
||
}
|
||
}
|
||
|
||
async bulkDelete() {
|
||
if (!confirm(`Are you sure you want to delete ${this.selectedPrompts.size} prompts?`)) return;
|
||
|
||
try {
|
||
const response = await ApiClient.fetchUrl("/prompt_manager/bulk/delete", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
prompt_ids: Array.from(this.selectedPrompts),
|
||
}),
|
||
});
|
||
|
||
if (response.ok) {
|
||
this.showNotification(`${this.selectedPrompts.size} prompts deleted`, "success");
|
||
this.selectedPrompts.clear();
|
||
await this.loadStatistics();
|
||
this.refreshList();
|
||
}
|
||
} catch (error) {
|
||
this.showNotification("Failed to delete prompts", "error");
|
||
}
|
||
}
|
||
|
||
async exportPrompts() {
|
||
try {
|
||
const response = await ApiClient.fetchUrl("/prompt_manager/export");
|
||
if (response.ok) {
|
||
const blob = await response.blob();
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `prompts-${new Date().toISOString().split("T")[0]}.json`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
window.URL.revokeObjectURL(url);
|
||
document.body.removeChild(a);
|
||
this.showNotification("Prompts exported", "success");
|
||
}
|
||
} catch (error) {
|
||
this.showNotification("Failed to export prompts", "error");
|
||
}
|
||
}
|
||
|
||
async showStats() {
|
||
await this.loadStatistics();
|
||
this.showNotification("📊 Stats refreshed successfully!", "success");
|
||
}
|
||
|
||
// Gallery functionality
|
||
async viewGallery(promptId) {
|
||
this.showModal("galleryModal");
|
||
|
||
// Show loading state
|
||
document.getElementById("galleryLoading").classList.remove("hidden");
|
||
document.getElementById("galleryContent").classList.add("hidden");
|
||
document.getElementById("galleryEmpty").classList.add("hidden");
|
||
|
||
try {
|
||
const response = await ApiClient.fetchUrl(`/prompt_manager/prompts/${promptId}/images`);
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
this.renderGallery(data.images, promptId);
|
||
} else {
|
||
throw new Error(data.error || 'Failed to load images');
|
||
}
|
||
} else {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
} catch (error) {
|
||
console.error('Gallery load error:', error);
|
||
this.showNotification('Failed to load gallery', 'error');
|
||
this.closeGallery();
|
||
} finally {
|
||
document.getElementById("galleryLoading").classList.add("hidden");
|
||
}
|
||
}
|
||
|
||
renderGallery(images, promptId) {
|
||
const content = document.getElementById("galleryContent");
|
||
const empty = document.getElementById("galleryEmpty");
|
||
|
||
// Update title
|
||
const prompt = this.prompts.find(p => p.id === promptId);
|
||
const promptText = prompt ? prompt.text.substring(0, 50) + (prompt.text.length > 50 ? '...' : '') : 'Unknown Prompt';
|
||
document.getElementById("galleryTitle").textContent = `Gallery: ${promptText}`;
|
||
|
||
if (!images || images.length === 0) {
|
||
content.classList.add("hidden");
|
||
empty.classList.remove("hidden");
|
||
return;
|
||
}
|
||
|
||
content.classList.remove("hidden");
|
||
empty.classList.add("hidden");
|
||
|
||
const formatFileSize = (bytes) => this.formatFileSize(bytes);
|
||
content.innerHTML = images.map((image, index) => {
|
||
const generated = new Date(image.generation_time);
|
||
const when = escapeHtml(`${generated.toLocaleDateString()} ${generated.toLocaleTimeString()}`);
|
||
const fileUrl = ApiClient.resolveUrl(`/prompt_manager/images/${escapeHtml(image.id)}/file`);
|
||
const caption = ImageHelpers.formatImageCaption(image, { formatFileSize });
|
||
const details = ImageHelpers.formatImageCaption(image, { formatFileSize, separator: ' • ' });
|
||
return `
|
||
<div class="group cursor-pointer bg-pm-surface rounded-pm-md overflow-hidden border border-pm hover:border-pm transition-all duration-200">
|
||
<div class="aspect-square bg-pm-surface overflow-hidden relative">
|
||
<img src="${fileUrl}"
|
||
alt="Generated image ${index + 1}"
|
||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||
data-original="${fileUrl}"
|
||
data-caption="Generated: ${when} | ${caption}"
|
||
onerror="this.parentElement.innerHTML='<div class=\\'flex items-center justify-center h-full text-pm-secondary\\'>⚠️ Image not found</div>'">
|
||
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-opacity duration-300 flex items-center justify-center pointer-events-none">
|
||
<svg class="w-8 h-8 text-pm opacity-0 group-hover:opacity-100 transition-opacity duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
<div class="p-3">
|
||
<div class="text-xs text-pm-secondary mb-1">
|
||
${when}
|
||
</div>
|
||
<div class="text-xs text-pm-muted">
|
||
${details}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
// Store images for navigation
|
||
this.currentGalleryImages = images;
|
||
|
||
// Initialize ViewerJS for the gallery after DOM is updated
|
||
setTimeout(() => {
|
||
if (this.galleryViewer) {
|
||
this.invalidateMetadataRequest();
|
||
this.galleryViewer.destroy();
|
||
}
|
||
|
||
this.galleryViewer = new Viewer(content, {
|
||
toolbar: {
|
||
zoomIn: 1,
|
||
zoomOut: 1,
|
||
oneToOne: 1,
|
||
reset: 1,
|
||
prev: 1,
|
||
play: {
|
||
show: 1,
|
||
size: 'large',
|
||
},
|
||
next: 1,
|
||
rotateLeft: 1,
|
||
rotateRight: 1,
|
||
flipHorizontal: 1,
|
||
flipVertical: 1,
|
||
},
|
||
navbar: true,
|
||
button: true,
|
||
title: true,
|
||
transition: true,
|
||
keyboard: true,
|
||
backdrop: true,
|
||
loading: true,
|
||
loop: true,
|
||
tooltip: true,
|
||
zoomRatio: 0.1,
|
||
minZoomRatio: 0.1,
|
||
maxZoomRatio: 4,
|
||
zoomOnTouch: true,
|
||
zoomOnWheel: true,
|
||
slideOnTouch: true,
|
||
toggleOnDblclick: false,
|
||
className: '',
|
||
shown: () => {
|
||
this.addMetadataSidebar();
|
||
},
|
||
viewed: (event) => {
|
||
this.loadMetadataForImage(event.detail.image);
|
||
},
|
||
hide: () => this.invalidateMetadataRequest(),
|
||
hidden: () => {
|
||
const sidebar = document.getElementById('metadata-sidebar');
|
||
if (sidebar) sidebar.remove();
|
||
}
|
||
});
|
||
}, 100);
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
invalidateMetadataRequest() {
|
||
this.metadataRequest = null;
|
||
this.currentMetadata = null;
|
||
}
|
||
|
||
closeGallery() {
|
||
this.invalidateMetadataRequest();
|
||
this.hideModal("galleryModal");
|
||
}
|
||
|
||
|
||
|
||
|
||
formatFileSize(bytes) {
|
||
if (!bytes) return 'Unknown';
|
||
const units = ['B', 'KB', 'MB', 'GB'];
|
||
let size = bytes;
|
||
let unitIndex = 0;
|
||
|
||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||
size /= 1024;
|
||
unitIndex++;
|
||
}
|
||
|
||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||
}
|
||
|
||
// Copy prompt to clipboard functionality
|
||
async copyPromptToClipboard(promptId) {
|
||
// Find the prompt text from the prompts array
|
||
const prompt = this.prompts.find(p => p.id === promptId);
|
||
if (!prompt) {
|
||
this.showNotification('❌ Prompt not found', 'error');
|
||
return;
|
||
}
|
||
|
||
const promptText = prompt.text;
|
||
|
||
try {
|
||
// Use the modern Clipboard API if available
|
||
if (navigator.clipboard && window.isSecureContext) {
|
||
await navigator.clipboard.writeText(promptText);
|
||
this.showNotification('📋 Prompt copied to clipboard!', 'success');
|
||
} else {
|
||
// Fallback for older browsers or non-secure contexts
|
||
const textArea = document.createElement('textarea');
|
||
textArea.value = promptText;
|
||
textArea.style.position = 'fixed';
|
||
textArea.style.left = '-999999px';
|
||
textArea.style.top = '-999999px';
|
||
document.body.appendChild(textArea);
|
||
textArea.focus();
|
||
textArea.select();
|
||
|
||
if (document.execCommand('copy')) {
|
||
this.showNotification('📋 Prompt copied to clipboard!', 'success');
|
||
} else {
|
||
throw new Error('Copy command failed');
|
||
}
|
||
|
||
document.body.removeChild(textArea);
|
||
}
|
||
} catch (error) {
|
||
console.error('Copy to clipboard failed:', error);
|
||
this.showNotification('❌ Failed to copy prompt to clipboard', 'error');
|
||
|
||
// Show a fallback modal with the text for manual copying
|
||
this.showCopyFallbackModal(promptText);
|
||
}
|
||
}
|
||
|
||
showCopyFallbackModal(text) {
|
||
// Create a temporary modal for manual copy
|
||
const modal = document.createElement('div');
|
||
modal.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50';
|
||
modal.innerHTML = `
|
||
<div class="bg-pm-surface rounded-pm-md p-4 max-w-2xl w-full mx-4 border border-pm">
|
||
<h3 class="text-sm font-semibold text-pm mb-3">Copy Prompt Text</h3>
|
||
<p class="text-pm-secondary mb-4">Please manually copy the text below:</p>
|
||
<textarea readonly class="w-full h-32 px-4 py-3 bg-pm-surface border border-pm rounded-pm-sm text-pm resize-none" style="font-family: monospace;"></textarea>
|
||
<div class="flex justify-end mt-4">
|
||
<button class="px-4 py-1.5 bg-pm-accent hover:bg-pm-accent-hover text-pm font-medium rounded-pm-sm transition-colors" onclick="this.closest('[class*=fixed]').remove()">
|
||
Close
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(modal);
|
||
document.body.style.overflow = "hidden";
|
||
|
||
// Auto-select the text in the textarea
|
||
const textarea = modal.querySelector('textarea');
|
||
textarea.value = text;
|
||
textarea.focus();
|
||
textarea.select();
|
||
|
||
// Close modal when clicking outside
|
||
modal.addEventListener('click', (e) => {
|
||
if (e.target === modal) {
|
||
modal.remove();
|
||
document.body.style.overflow = "";
|
||
}
|
||
});
|
||
|
||
// Also handle the close button
|
||
modal.querySelector('button').addEventListener('click', () => {
|
||
document.body.style.overflow = "";
|
||
});
|
||
}
|
||
|
||
// Scan functionality
|
||
//
|
||
// The scan is a background job on the server. The modal shows its progress,
|
||
// can be hidden to a small pill with the Hide button while it runs (backdrop
|
||
// clicks are ignored), and re-attaches to a scan still running after a reload.
|
||
showScanModal() {
|
||
if (this.scanState.status !== ScanProgress.RUNNING) this.resetScanControls();
|
||
document.getElementById("scanPill").classList.add("hidden");
|
||
this.showModal("scanModal");
|
||
}
|
||
|
||
resetScanControls() {
|
||
document.getElementById("scanProgress").classList.add("hidden");
|
||
const start = document.getElementById("startScan");
|
||
start.disabled = false;
|
||
start.textContent = "Start Scan";
|
||
document.getElementById("cancelScan").textContent = "Cancel";
|
||
}
|
||
|
||
dismissScanModal() {
|
||
if (ScanProgress.dismissAction(this.scanState) === "minimize") {
|
||
this.minimizeScan();
|
||
} else {
|
||
this.hideModal("scanModal");
|
||
}
|
||
}
|
||
|
||
minimizeScan() {
|
||
this.hideModal("scanModal");
|
||
document.getElementById("scanPillText").textContent = ScanProgress.progressLabel(this.scanState);
|
||
document.getElementById("scanPill").classList.remove("hidden");
|
||
}
|
||
|
||
warnIfScanRunning(e) {
|
||
if (this.scanState.status !== ScanProgress.RUNNING) return;
|
||
// The scan keeps running on the server; the prompt only guards the progress view.
|
||
e.preventDefault();
|
||
e.returnValue = "";
|
||
}
|
||
|
||
startScan() {
|
||
if (this.scanState.status === ScanProgress.RUNNING) return;
|
||
this.scanState = ScanProgress.reduceScanEvent(ScanProgress.initialState(), {
|
||
type: "progress", progress: 0, status: "Initializing scan...", processed: 0, found: 0,
|
||
});
|
||
this.renderScanState();
|
||
this.attachScanStream();
|
||
}
|
||
|
||
attachScanStream() {
|
||
if (this.scanSource) this.scanSource.close();
|
||
// POST starts a scan, or attaches to the one already running.
|
||
const source = SseStream.connect("/prompt_manager/scan", { method: "POST", body: "{}" });
|
||
this.scanSource = source;
|
||
source.onmessage = (e) => {
|
||
let data;
|
||
try {
|
||
data = JSON.parse(e.data);
|
||
} catch (_) {
|
||
return;
|
||
}
|
||
this.applyScanEvent(data);
|
||
};
|
||
source.onerror = () => {
|
||
if (this.scanSource !== source || this.scanState.status !== ScanProgress.RUNNING) return;
|
||
this.showNotification("Lost the connection to the scan; it is still running on the server. Reconnecting...", "warning");
|
||
setTimeout(() => this.resumeRunningScan(), 2000);
|
||
};
|
||
source.onclose = () => {
|
||
// The stream ended without a complete/error event (server restart): ask the server.
|
||
if (this.scanSource === source && this.scanState.status === ScanProgress.RUNNING) this.resumeRunningScan();
|
||
};
|
||
}
|
||
|
||
async resumeRunningScan() {
|
||
let status;
|
||
try {
|
||
const response = await ApiClient.fetchUrl("/prompt_manager/scan/status");
|
||
if (!response.ok) return;
|
||
status = await response.json();
|
||
} catch (_) {
|
||
return;
|
||
}
|
||
const state = ScanProgress.fromStatus(status);
|
||
if (state.status !== ScanProgress.RUNNING) {
|
||
if (this.scanState.status === ScanProgress.RUNNING) {
|
||
// A scan we were following ended while we were disconnected.
|
||
const last = status.last_event;
|
||
const terminal = last && (last.type === "complete" || last.type === "cancelled" || last.type === "error");
|
||
this.applyScanEvent(
|
||
terminal ? last : { type: "error", message: "the server no longer reports a running scan" }
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
this.scanState = state;
|
||
this.renderScanState();
|
||
if (document.getElementById("scanModal").classList.contains("hidden")) this.minimizeScan();
|
||
this.attachScanStream();
|
||
}
|
||
|
||
applyScanEvent(data) {
|
||
const next = ScanProgress.reduceScanEvent(this.scanState, data);
|
||
if (next === this.scanState) return;
|
||
this.scanState = next;
|
||
this.renderScanState();
|
||
if (next.status === ScanProgress.DONE) this.completeScan(next);
|
||
else if (next.status === ScanProgress.FAILED) this.failScan(next);
|
||
}
|
||
|
||
renderScanState() {
|
||
const state = this.scanState;
|
||
if (state.status === ScanProgress.IDLE) return;
|
||
const running = state.status === ScanProgress.RUNNING;
|
||
document.getElementById("scanProgress").classList.remove("hidden");
|
||
const start = document.getElementById("startScan");
|
||
start.disabled = running;
|
||
start.textContent = running ? "Scanning..." : "Start New Scan";
|
||
document.getElementById("cancelScan").textContent = running ? "Hide" : "Close";
|
||
document.getElementById("scanPillText").textContent = ScanProgress.progressLabel(state);
|
||
this.updateScanProgress(state.progress, state.statusText, state.processed, state.found);
|
||
}
|
||
|
||
async quickBackup() {
|
||
try {
|
||
const response = await ApiClient.fetchUrl("/prompt_manager/backup");
|
||
if (response.ok) {
|
||
const blob = await response.blob();
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `prompts-backup-${new Date().toISOString().split("T")[0]}.db`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
window.URL.revokeObjectURL(url);
|
||
document.body.removeChild(a);
|
||
this.showNotification("Database backup created successfully!", "success");
|
||
} else {
|
||
throw new Error("Backup failed");
|
||
}
|
||
} catch (error) {
|
||
this.showNotification("Failed to create backup", "error");
|
||
}
|
||
}
|
||
|
||
updateScanProgress(progress, status, processed, found) {
|
||
document.getElementById("scanProgressBar").style.width = `${progress}%`;
|
||
document.getElementById("scanStatusText").textContent = status;
|
||
document.getElementById("scanCount").textContent = `${processed} files processed`;
|
||
document.getElementById("scanFound").textContent = `${found} prompts found`;
|
||
}
|
||
|
||
completeScan(state) {
|
||
document.getElementById("scanPill").classList.add("hidden");
|
||
this.showNotification(ScanProgress.completionLabel(state), "success");
|
||
|
||
// Auto-close after a short delay so the completion message is visible
|
||
setTimeout(() => {
|
||
this.hideModal("scanModal");
|
||
this.loadStatistics();
|
||
this.refreshList();
|
||
}, 2000);
|
||
}
|
||
|
||
failScan(state) {
|
||
document.getElementById("scanPill").classList.add("hidden");
|
||
this.showNotification(`Scan failed: ${state.message}`, "error");
|
||
}
|
||
|
||
// Pagination methods
|
||
updatePaginationControls() {
|
||
const { currentPage, totalPages, total, limit } = this.pagination;
|
||
|
||
// Update pagination info
|
||
document.getElementById("currentPage").textContent = currentPage;
|
||
document.getElementById("totalPages").textContent = totalPages;
|
||
document.getElementById("totalResults").textContent = total;
|
||
|
||
// Calculate showing range
|
||
const start = ((currentPage - 1) * limit) + 1;
|
||
const end = Math.min(currentPage * limit, total);
|
||
document.getElementById("showingStart").textContent = start;
|
||
document.getElementById("showingEnd").textContent = end;
|
||
|
||
// Update results count
|
||
document.getElementById("resultsCount").textContent = `${this.prompts.length} of ${total}`;
|
||
|
||
// Update button states
|
||
document.getElementById("firstPageBtn").disabled = currentPage === 1;
|
||
document.getElementById("prevPageBtn").disabled = currentPage === 1;
|
||
document.getElementById("nextPageBtn").disabled = currentPage === totalPages;
|
||
document.getElementById("lastPageBtn").disabled = currentPage === totalPages;
|
||
}
|
||
|
||
async goToPage(page) {
|
||
if (page < 1 || page > this.pagination.totalPages || page === this.pagination.currentPage) {
|
||
return;
|
||
}
|
||
await (this.listMode === "search" ? this.search(page) : this.loadRecentPrompts(page));
|
||
// Scroll to top of results after page loads
|
||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||
}
|
||
|
||
async changeLimit(newLimit) {
|
||
this.pagination.limit = newLimit;
|
||
await this.reloadPrompts(); // back to the first page of the current list
|
||
}
|
||
|
||
// Metadata functionality
|
||
openMetadataViewer() {
|
||
window.open(ApiClient.resolveUrl('/prompt_manager/gallery.html'), '_blank');
|
||
}
|
||
|
||
// Gallery functionality
|
||
openGallery() {
|
||
window.open(ApiClient.resolveUrl('/prompt_manager/gallery'), '_blank');
|
||
}
|
||
|
||
|
||
async copyToClipboard(text) {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
this.showNotification('Copied to clipboard!', 'success');
|
||
} catch (err) {
|
||
console.error('Copy failed:', err);
|
||
this.showNotification('Copy failed', 'error');
|
||
}
|
||
}
|
||
|
||
parsePNGMetadata(arrayBuffer) {
|
||
return PngMetadata.parsePngTextChunks(arrayBuffer);
|
||
}
|
||
|
||
extractComfyUIData(metadata) {
|
||
// Look for ComfyUI workflow data in various possible fields
|
||
let workflowData = null;
|
||
let promptData = null;
|
||
|
||
// Common ComfyUI metadata field names
|
||
const workflowFields = ['workflow', 'Workflow', 'comfy', 'ComfyUI'];
|
||
const promptFields = ['prompt', 'Prompt', 'parameters', 'Parameters'];
|
||
|
||
for (const field of workflowFields) {
|
||
if (metadata[field]) {
|
||
try {
|
||
// Clean NaN values from JSON string before parsing
|
||
let cleanedJson = metadata[field];
|
||
cleanedJson = cleanedJson.replace(/:\s*NaN\b/g, ': null');
|
||
cleanedJson = cleanedJson.replace(/\bNaN\b/g, 'null');
|
||
|
||
workflowData = JSON.parse(cleanedJson);
|
||
break;
|
||
} catch (e) {
|
||
console.warn('Failed to parse workflow field:', field);
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const field of promptFields) {
|
||
if (metadata[field]) {
|
||
try {
|
||
// Clean NaN values from JSON string before parsing
|
||
let cleanedJson = metadata[field];
|
||
cleanedJson = cleanedJson.replace(/:\s*NaN\b/g, ': null');
|
||
cleanedJson = cleanedJson.replace(/\bNaN\b/g, 'null');
|
||
|
||
promptData = JSON.parse(cleanedJson);
|
||
break;
|
||
} catch (e) {
|
||
console.warn('Failed to parse prompt field:', field);
|
||
}
|
||
}
|
||
}
|
||
|
||
return { workflow: workflowData, prompt: promptData };
|
||
}
|
||
|
||
async extractImageMetadata(imageUrl) {
|
||
try {
|
||
// Fetch the image as an array buffer
|
||
const response = await ApiClient.fetchUrl(imageUrl);
|
||
const arrayBuffer = await response.arrayBuffer();
|
||
|
||
// Parse PNG metadata
|
||
const metadata = await this.parsePNGMetadata(arrayBuffer);
|
||
const comfyData = this.extractComfyUIData(metadata);
|
||
|
||
return this.parseWorkflowData(comfyData, imageUrl);
|
||
} catch (error) {
|
||
console.error('Error extracting metadata:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
parseWorkflowData(comfyData, imagePath) {
|
||
// Trace prompts, model and sampler settings through the node graph (#75)
|
||
const {
|
||
checkpoint, positivePrompt, negativePrompt, steps, cfgScale, sampler, seed,
|
||
} = window.ComfyMetadata.extractGenerationParams(comfyData);
|
||
|
||
return {
|
||
positivePrompt,
|
||
negativePrompt,
|
||
checkpoint,
|
||
steps,
|
||
cfgScale,
|
||
sampler,
|
||
seed,
|
||
workflow: comfyData.workflow,
|
||
prompt: comfyData.prompt,
|
||
imagePath
|
||
};
|
||
}
|
||
|
||
addMetadataSidebar() {
|
||
// Find the active (visible) viewer container — there may be stale
|
||
// hidden ones from previously opened viewers still in the DOM
|
||
const containers = document.querySelectorAll('.viewer-container');
|
||
let viewerContainer = null;
|
||
for (const c of containers) {
|
||
if (c.style.display !== 'none') {
|
||
viewerContainer = c;
|
||
}
|
||
}
|
||
if (!viewerContainer || document.getElementById('metadata-sidebar')) return;
|
||
|
||
const sidebar = document.createElement('div');
|
||
sidebar.id = 'metadata-sidebar';
|
||
sidebar.className = 'metadata-sidebar';
|
||
sidebar.innerHTML = `
|
||
<!-- Header -->
|
||
<div class="flex items-center justify-between p-4 border-b border-pm">
|
||
<div class="flex items-center gap-2">
|
||
<svg class="w-5 h-5 text-pm-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||
</svg>
|
||
<h1 class="text-sm font-semibold text-pm">Generation data</h1>
|
||
</div>
|
||
<button class="text-xs text-pm-accent hover:text-pm-accent transition-colors metadata-copy-all">
|
||
📋 COPY ALL
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Scrollable Content -->
|
||
<div id="metadata-content" class="flex-1 overflow-y-auto p-4 space-y-3">
|
||
<!-- Initial placeholder content -->
|
||
<div class="text-center text-pm-muted py-8">
|
||
<svg class="w-16 h-16 mx-auto mb-4 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||
</svg>
|
||
<p class="text-sm mb-2">Loading Metadata...</p>
|
||
<p class="text-sm">Extracting ComfyUI workflow data</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
viewerContainer.appendChild(sidebar);
|
||
this.attachMetadataEventListeners();
|
||
}
|
||
|
||
async loadMetadataForImage(imgElement) {
|
||
const imageUrl = imgElement.getAttribute('data-original') || imgElement.src;
|
||
if (!imageUrl) return;
|
||
|
||
const metadataContent = document.getElementById('metadata-content');
|
||
if (!metadataContent) return;
|
||
|
||
const request = {};
|
||
this.metadataRequest = request;
|
||
this.currentMetadata = null;
|
||
const isCurrent = () => this.metadataRequest === request && metadataContent.isConnected !== false;
|
||
|
||
// Show loading state
|
||
metadataContent.innerHTML = `
|
||
<div class="metadata-loading">
|
||
<div class="text-center text-pm-muted py-8">
|
||
<svg class="w-16 h-16 mx-auto mb-4 opacity-30 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||
</svg>
|
||
<p class="text-sm mb-2">Loading Metadata...</p>
|
||
<p class="text-sm">Extracting ComfyUI workflow data</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
try {
|
||
const metadata = await this.extractImageMetadata(imageUrl);
|
||
if (!isCurrent()) return;
|
||
this.currentMetadata = metadata;
|
||
this.updateMetadataSidebar(metadata);
|
||
} catch (error) {
|
||
if (!isCurrent()) return;
|
||
console.error('Failed to load metadata:', error);
|
||
metadataContent.innerHTML = `
|
||
<div class="text-center text-pm-error py-8">
|
||
<p class="text-sm mb-2">Failed to Load Metadata</p>
|
||
<p class="text-sm">Could not extract ComfyUI workflow data</p>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
updateMetadataSidebar(metadata) {
|
||
const metadataContent = document.getElementById('metadata-content');
|
||
if (!metadataContent || !metadata) return;
|
||
|
||
metadataContent.innerHTML = `
|
||
<!-- File Path -->
|
||
<div>
|
||
<h2 class="text-sm font-medium text-pm-secondary mb-2">File Path</h2>
|
||
<div class="text-sm text-pm-accent hover:text-pm-accent cursor-pointer bg-pm-surface p-2 rounded break-all" data-copy-path="">
|
||
${escapeHtml(metadata.imagePath)}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Resources used -->
|
||
<div>
|
||
<h2 class="text-sm font-medium text-pm-secondary mb-2">Resources used</h2>
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<div class="text-pm-accent hover:text-pm-accent cursor-pointer">${escapeHtml(metadata.checkpoint)}</div>
|
||
<div class="text-xs text-pm-muted">ComfyUI Generated</div>
|
||
</div>
|
||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded">CHECKPOINT</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Prompt -->
|
||
<div>
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<h2 class="text-sm font-medium text-pm-secondary">Prompt</h2>
|
||
<span class="px-2 py-1 text-xs bg-orange-600 text-orange-100 rounded">COMFYUI</span>
|
||
<button class="ml-auto text-pm-secondary hover:text-pm-secondary" data-copy-type="positive">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="text-sm text-pm-secondary bg-pm-surface p-3 rounded max-h-32 overflow-y-auto">
|
||
${escapeHtml(metadata.positivePrompt.substring(0, 200))}${metadata.positivePrompt.length > 200 ? '...' : ''}
|
||
</div>
|
||
${metadata.positivePrompt.length > 200 ? '<button class="text-pm-accent hover:text-pm-accent text-sm mt-1" data-show-type="positive">Show more</button>' : ''}
|
||
</div>
|
||
|
||
<!-- Negative prompt -->
|
||
<div>
|
||
<div class="flex items-center justify-between mb-2">
|
||
<h2 class="text-sm font-medium text-pm-secondary">Negative prompt</h2>
|
||
<button class="text-pm-secondary hover:text-pm-secondary" data-copy-type="negative">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="text-sm text-pm-secondary bg-pm-surface p-3 rounded max-h-32 overflow-y-auto">
|
||
${escapeHtml(metadata.negativePrompt.substring(0, 200))}${metadata.negativePrompt.length > 200 ? '...' : ''}
|
||
</div>
|
||
${metadata.negativePrompt.length > 200 ? '<button class="text-pm-accent hover:text-pm-accent text-sm mt-1" data-show-type="negative">Show more</button>' : ''}
|
||
</div>
|
||
|
||
<!-- Other metadata -->
|
||
<div>
|
||
<h2 class="text-sm font-medium text-pm-secondary mb-3">Other metadata</h2>
|
||
<div class="flex flex-wrap gap-2">
|
||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded">CFG SCALE: ${escapeHtml(metadata.cfgScale)}</span>
|
||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded">STEPS: ${escapeHtml(metadata.steps)}</span>
|
||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded">SAMPLER: ${escapeHtml(metadata.sampler)}</span>
|
||
</div>
|
||
<div class="mt-2">
|
||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded">SEED: ${escapeHtml(metadata.seed)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Raw Workflow Data -->
|
||
<div>
|
||
<h2 class="text-sm font-medium text-pm-secondary mb-2">ComfyUI Workflow</h2>
|
||
<div class="flex items-center gap-2">
|
||
<button class="text-pm-accent hover:text-pm-accent text-sm" data-action="show-workflow">View Raw Workflow JSON</button>
|
||
<button class="text-pm-accent hover:text-pm-accent" data-action="download-workflow" title="Download JSON">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Re-attach event listeners after updating content
|
||
this.attachMetadataEventListeners();
|
||
}
|
||
|
||
attachMetadataEventListeners() {
|
||
const sidebar = document.getElementById('metadata-sidebar');
|
||
if (!sidebar) return;
|
||
|
||
// Copy buttons
|
||
const copyBtns = sidebar.querySelectorAll('[data-copy-type]');
|
||
copyBtns.forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
const type = btn.getAttribute('data-copy-type');
|
||
if (this.currentMetadata) {
|
||
const text = type === 'positive' ? this.currentMetadata.positivePrompt : this.currentMetadata.negativePrompt;
|
||
this.copyToClipboard(text);
|
||
}
|
||
});
|
||
});
|
||
|
||
// File path copy
|
||
const pathEl = sidebar.querySelector('[data-copy-path]');
|
||
if (pathEl) {
|
||
pathEl.addEventListener('click', () => {
|
||
if (this.currentMetadata) {
|
||
this.copyToClipboard(this.currentMetadata.imagePath);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Show more buttons
|
||
const showMoreBtns = sidebar.querySelectorAll('[data-show-type]');
|
||
showMoreBtns.forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
const type = btn.getAttribute('data-show-type');
|
||
this.showFullPrompt(type);
|
||
});
|
||
});
|
||
|
||
// Workflow action buttons
|
||
const actionBtns = sidebar.querySelectorAll('[data-action]');
|
||
actionBtns.forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
const action = btn.getAttribute('data-action');
|
||
if (action === 'show-workflow') {
|
||
this.showWorkflowData();
|
||
} else if (action === 'download-workflow') {
|
||
this.downloadWorkflowJSON();
|
||
}
|
||
});
|
||
});
|
||
|
||
// Copy all button
|
||
const copyAllBtn = sidebar.querySelector('.metadata-copy-all');
|
||
if (copyAllBtn) {
|
||
copyAllBtn.addEventListener('click', () => {
|
||
this.copyAllMetadata();
|
||
});
|
||
}
|
||
}
|
||
|
||
showFullPrompt(type) {
|
||
if (this.currentMetadata) {
|
||
const prompt = type === 'positive' ? this.currentMetadata.positivePrompt : this.currentMetadata.negativePrompt;
|
||
const safeType = escapeHtml(type.charAt(0).toUpperCase() + type.slice(1));
|
||
const newWindow = window.open('', '_blank');
|
||
const doc = newWindow.document;
|
||
doc.open();
|
||
doc.write('<!DOCTYPE html><html><head><title>' + safeType + ' Prompt</title></head><body></body></html>');
|
||
doc.close();
|
||
doc.body.style.cssText = 'background:#111;color:#fff;font-family:monospace;padding:20px;';
|
||
const h2 = doc.createElement('h2');
|
||
h2.textContent = type.charAt(0).toUpperCase() + type.slice(1) + ' Prompt';
|
||
doc.body.appendChild(h2);
|
||
const pre = doc.createElement('pre');
|
||
pre.style.cssText = 'background:#222;padding:15px;border-radius:5px;white-space:pre-wrap;line-height:1.5;';
|
||
pre.textContent = prompt;
|
||
doc.body.appendChild(pre);
|
||
const btn = doc.createElement('button');
|
||
btn.textContent = 'Copy to Clipboard';
|
||
btn.style.cssText = 'margin-top:20px;padding:10px 20px;background:#444;color:#fff;border:none;border-radius:5px;cursor:pointer;';
|
||
btn.addEventListener('click', () => { navigator.clipboard.writeText(pre.textContent).then(() => alert('Copied!')); });
|
||
doc.body.appendChild(btn);
|
||
}
|
||
}
|
||
|
||
showWorkflowData() {
|
||
if (this.currentMetadata && this.currentMetadata.workflow) {
|
||
const newWindow = window.open('', '_blank');
|
||
if (!newWindow) {
|
||
this.showNotification('Popup blocked: allow popups to view the workflow', 'warning');
|
||
return;
|
||
}
|
||
newWindow.document.write(ImageHelpers.workflowDocumentHtml(this.currentMetadata.workflow));
|
||
newWindow.document.close();
|
||
}
|
||
}
|
||
|
||
downloadWorkflowJSON() {
|
||
if (this.currentMetadata && this.currentMetadata.workflow) {
|
||
const dataStr = JSON.stringify(this.currentMetadata.workflow, null, 2);
|
||
const dataBlob = new Blob([dataStr], {type: 'application/json'});
|
||
const url = URL.createObjectURL(dataBlob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = 'comfyui_workflow.json';
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
}
|
||
|
||
copyAllMetadata() {
|
||
if (!this.currentMetadata) return;
|
||
|
||
const allData = `Checkpoint: ${this.currentMetadata.checkpoint || 'Unknown'}
|
||
Positive Prompt: ${this.currentMetadata.positivePrompt}
|
||
Negative Prompt: ${this.currentMetadata.negativePrompt}
|
||
Steps: ${this.currentMetadata.steps || 'Unknown'}
|
||
CFG Scale: ${this.currentMetadata.cfgScale || 'Unknown'}
|
||
Sampler: ${this.currentMetadata.sampler || 'Unknown'}
|
||
Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||
|
||
this.copyToClipboard(allData);
|
||
}
|
||
|
||
// ==========================================
|
||
// Auto Tag Feature Methods
|
||
// ==========================================
|
||
|
||
// State for AutoTag feature
|
||
autoTagState = {
|
||
eventSource: null,
|
||
reviewImages: [],
|
||
reviewIndex: 0,
|
||
currentTags: [],
|
||
cancelled: false,
|
||
skipAllTagged: false,
|
||
retagResolve: null, // Promise resolver for retag confirmation
|
||
tagsExpanded: false // Track accordion state
|
||
};
|
||
|
||
// ==================== Add Prompt Modal ====================
|
||
|
||
showAddPromptModal() {
|
||
// Clear form
|
||
document.getElementById('addPromptText').value = '';
|
||
document.getElementById('addPromptCategory').value = '';
|
||
document.getElementById('addPromptRatingValue').value = '';
|
||
document.getElementById('addPromptNotes').value = '';
|
||
document.getElementById('addPromptProtected').checked = true;
|
||
|
||
// Clear tags
|
||
this.addPromptTags = [];
|
||
this.renderAddPromptTags();
|
||
|
||
// Reset rating stars
|
||
this.updateAddPromptRatingStars(0);
|
||
|
||
// Populate categories datalist
|
||
this.populateAddPromptCategories();
|
||
|
||
this.showModal('addPromptModal');
|
||
}
|
||
|
||
setupAddPromptModal() {
|
||
this.addPromptTags = [];
|
||
|
||
// Rating stars
|
||
const ratingContainer = document.getElementById('addPromptRating');
|
||
const ratingStars = ratingContainer.querySelectorAll('.rating-star');
|
||
ratingStars.forEach(star => {
|
||
star.addEventListener('click', () => {
|
||
const rating = parseInt(star.dataset.rating);
|
||
document.getElementById('addPromptRatingValue').value = rating;
|
||
this.updateAddPromptRatingStars(rating);
|
||
});
|
||
});
|
||
|
||
// Clear rating button
|
||
document.getElementById('clearAddPromptRatingBtn').addEventListener('click', () => {
|
||
document.getElementById('addPromptRatingValue').value = '';
|
||
this.updateAddPromptRatingStars(0);
|
||
});
|
||
|
||
// Tag input
|
||
const tagInput = document.getElementById('addPromptTagInput');
|
||
tagInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' || e.key === ',') {
|
||
e.preventDefault();
|
||
const tag = tagInput.value.trim().replace(/,/g, '');
|
||
if (tag && !this.addPromptTags.includes(tag)) {
|
||
this.addPromptTags.push(tag);
|
||
this.renderAddPromptTags();
|
||
}
|
||
tagInput.value = '';
|
||
}
|
||
});
|
||
|
||
// Tag suggestions
|
||
tagInput.addEventListener('input', () => {
|
||
this.showAddPromptTagSuggestions(tagInput.value);
|
||
});
|
||
}
|
||
|
||
updateAddPromptRatingStars(rating) {
|
||
const stars = document.querySelectorAll('#addPromptRating .rating-star');
|
||
stars.forEach((star, index) => {
|
||
if (index < rating) {
|
||
star.textContent = '★';
|
||
star.classList.add('text-pm-warning');
|
||
star.classList.remove('text-pm-muted');
|
||
} else {
|
||
star.textContent = '☆';
|
||
star.classList.remove('text-pm-warning');
|
||
star.classList.add('text-pm-muted');
|
||
}
|
||
});
|
||
}
|
||
|
||
renderAddPromptTags() {
|
||
const container = document.getElementById('addPromptTagsContainer');
|
||
const input = document.getElementById('addPromptTagInput');
|
||
|
||
// Remove existing tag chips
|
||
container.querySelectorAll('.tag-chip').forEach(chip => chip.remove());
|
||
|
||
// Add tag chips
|
||
this.addPromptTags.forEach(tag => {
|
||
const chip = document.createElement('span');
|
||
chip.className = 'tag-chip inline-flex items-center px-2 py-1 bg-pm-accent text-pm text-xs rounded cursor-pointer hover:bg-pm-accent-hover';
|
||
chip.innerHTML = `${escapeHtml(tag)} <span class="ml-1">×</span>`;
|
||
chip.addEventListener('click', () => {
|
||
this.addPromptTags = this.addPromptTags.filter(t => t !== tag);
|
||
this.renderAddPromptTags();
|
||
});
|
||
container.insertBefore(chip, input);
|
||
});
|
||
}
|
||
|
||
showAddPromptTagSuggestions(query) {
|
||
const suggestionsContainer = document.getElementById('addPromptTagSuggestions');
|
||
if (!query) {
|
||
suggestionsContainer.classList.add('hidden');
|
||
return;
|
||
}
|
||
|
||
const matchingTags = this.tags.filter(tag =>
|
||
tag.toLowerCase().includes(query.toLowerCase()) &&
|
||
!this.addPromptTags.includes(tag)
|
||
).slice(0, 10);
|
||
|
||
if (matchingTags.length === 0) {
|
||
suggestionsContainer.classList.add('hidden');
|
||
return;
|
||
}
|
||
|
||
suggestionsContainer.innerHTML = matchingTags.map(tag => `
|
||
<div class="px-3 py-2 hover:bg-pm-hover cursor-pointer text-sm text-pm" data-tag="${escapeHtml(tag)}">
|
||
${escapeHtml(tag)}
|
||
</div>
|
||
`).join('');
|
||
|
||
suggestionsContainer.querySelectorAll('[data-tag]').forEach(el => {
|
||
el.addEventListener('click', () => {
|
||
const tag = el.dataset.tag;
|
||
if (!this.addPromptTags.includes(tag)) {
|
||
this.addPromptTags.push(tag);
|
||
this.renderAddPromptTags();
|
||
}
|
||
document.getElementById('addPromptTagInput').value = '';
|
||
suggestionsContainer.classList.add('hidden');
|
||
});
|
||
});
|
||
|
||
suggestionsContainer.classList.remove('hidden');
|
||
}
|
||
|
||
populateAddPromptCategories() {
|
||
const datalist = document.getElementById('addPromptCategoryList');
|
||
datalist.innerHTML = this.categories.map(cat => `<option value="${escapeHtml(cat)}">`).join('');
|
||
}
|
||
|
||
async saveNewPrompt() {
|
||
const text = document.getElementById('addPromptText').value.trim();
|
||
const category = document.getElementById('addPromptCategory').value.trim();
|
||
const rating = document.getElementById('addPromptRatingValue').value;
|
||
const notes = document.getElementById('addPromptNotes').value.trim();
|
||
const isProtected = document.getElementById('addPromptProtected').checked;
|
||
|
||
if (!text) {
|
||
this.showNotification('Prompt text is required', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await ApiClient.fetchUrl('/prompt_manager/save', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
text: text,
|
||
category: category || null,
|
||
rating: rating ? parseInt(rating) : null,
|
||
tags: this.addPromptTags,
|
||
notes: notes || null,
|
||
is_protected: isProtected
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
this.showNotification('Prompt added successfully!', 'success');
|
||
this.hideModal('addPromptModal');
|
||
// Refresh the prompts list
|
||
this.refreshList();
|
||
// Reload categories and tags in case new ones were added
|
||
this.loadCategories();
|
||
this.loadTags();
|
||
} else {
|
||
this.showNotification(data.error || 'Failed to add prompt', 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error adding prompt:', error);
|
||
this.showNotification('Failed to add prompt', 'error');
|
||
}
|
||
}
|
||
|
||
// ==================== End Add Prompt Modal ====================
|
||
|
||
// ==================== Film Strip Functions ====================
|
||
|
||
getImageUrl(image) {
|
||
// If the image already has a url property, use it
|
||
if (image.url) return ApiClient.resolveUrl(image.url);
|
||
|
||
// Otherwise construct from image_path
|
||
if (image.image_path) {
|
||
// Extract relative path from the full path
|
||
// The image_path might be absolute, we need to serve it via the API
|
||
const path = image.image_path;
|
||
// Try to get filename for serving
|
||
const filename = image.filename || path.split(/[/\\]/).pop();
|
||
// Use the image ID if available, otherwise use the path-based serve endpoint
|
||
if (image.id) {
|
||
return ApiClient.resolveUrl(`/prompt_manager/images/${image.id}/file`);
|
||
}
|
||
// Fallback: serve by path (encode the path)
|
||
return ApiClient.resolveUrl(`/prompt_manager/images/serve/${encodeURIComponent(path)}`);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
getThumbnailUrl(image) {
|
||
// If thumbnail_url already exists, use it
|
||
if (image.thumbnail_url) return ApiClient.resolveUrl(image.thumbnail_url);
|
||
|
||
// Try to construct thumbnail URL from filename and relative_path
|
||
// An older release's name: the server answers it with the current preview
|
||
// (any format the file owns, #317), or makes it on first view.
|
||
// Thumbnails are stored as: thumbnails/{parent_dir}/{stem}_thumb{ext}
|
||
// to avoid collisions with same-named files in different directories
|
||
if (image.filename) {
|
||
const lastDot = image.filename.lastIndexOf('.');
|
||
if (lastDot > 0) {
|
||
const stem = image.filename.substring(0, lastDot);
|
||
const ext = image.filename.substring(lastDot);
|
||
|
||
// Get parent directory from relative_path if available
|
||
let parentDir = '';
|
||
if (image.relative_path) {
|
||
const relPath = image.relative_path.replace(/\\/g, '/');
|
||
const lastSlash = relPath.lastIndexOf('/');
|
||
if (lastSlash > 0) {
|
||
parentDir = relPath.substring(0, lastSlash) + '/';
|
||
}
|
||
}
|
||
|
||
// URL encode the path components (but preserve slashes)
|
||
const encodedParentDir = parentDir.split('/').map(p => encodeURIComponent(p)).join('/');
|
||
return ApiClient.resolveUrl(`/prompt_manager/images/serve/thumbnails/${encodedParentDir}${encodeURIComponent(stem)}_thumb${ext}`);
|
||
}
|
||
}
|
||
|
||
// Fall back to full image URL
|
||
return this.getImageUrl(image);
|
||
}
|
||
|
||
async loadFilmStripImages(promptId) {
|
||
try {
|
||
const response = await ApiClient.fetchUrl(`/prompt_manager/prompts/${promptId}/images`);
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
if (data.success && data.images) {
|
||
return data.images;
|
||
}
|
||
}
|
||
return [];
|
||
} catch (error) {
|
||
console.error(`Error loading images for prompt ${promptId}:`, error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
createFilmStrip(images, promptId, maxThumbnails = 5) {
|
||
if (!images || images.length === 0) {
|
||
return `<div class="film-strip-empty">No images</div>`;
|
||
}
|
||
|
||
const displayImages = images.slice(0, maxThumbnails);
|
||
const remaining = images.length - maxThumbnails;
|
||
|
||
let html = `<div class="prompt-film-strip film-strip-size-medium" data-prompt-id="${promptId}">`;
|
||
|
||
displayImages.forEach((image, index) => {
|
||
const imageUrl = this.getImageUrl(image);
|
||
const thumbnailUrl = this.getThumbnailUrl(image);
|
||
|
||
if (!thumbnailUrl) {
|
||
// Skip images with no valid URL
|
||
return;
|
||
}
|
||
|
||
html += `
|
||
<div class="film-strip-thumbnail"
|
||
data-action="open-film"
|
||
data-index="${index}"
|
||
data-prompt-id="${promptId}">
|
||
<img src="${escapeHtml(thumbnailUrl)}"
|
||
alt="Image ${index + 1}"
|
||
loading="lazy"
|
||
data-fallback-src="${escapeHtml(imageUrl)}">
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
if (remaining > 0) {
|
||
html += `
|
||
<div class="film-strip-thumbnail film-strip-thumbnail--more"
|
||
data-action="gallery" data-prompt-id="${promptId}">
|
||
+${remaining}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
html += `</div>`;
|
||
return html;
|
||
}
|
||
|
||
async openFilmStripViewer(promptId, startIndex = 0) {
|
||
// Use the existing gallery function to open the viewer
|
||
// Store images temporarily for the viewer
|
||
const images = await this.loadFilmStripImages(promptId);
|
||
if (images.length === 0) {
|
||
this.showNotification('No images found for this prompt', 'warning');
|
||
return;
|
||
}
|
||
|
||
// Store for viewer
|
||
this.filmStripImages = images;
|
||
this.filmStripCurrentPrompt = promptId;
|
||
|
||
// Create a temporary container for ViewerJS
|
||
const container = document.createElement('div');
|
||
container.id = 'filmStripViewerContainer';
|
||
container.style.display = 'none';
|
||
|
||
images.forEach((image, index) => {
|
||
const imageUrl = this.getImageUrl(image);
|
||
if (!imageUrl) return;
|
||
|
||
const img = document.createElement('img');
|
||
img.src = imageUrl;
|
||
img.alt = image.filename || `Image ${index + 1}`;
|
||
img.dataset.caption = this.formatFilmStripCaption(image);
|
||
container.appendChild(img);
|
||
});
|
||
|
||
document.body.appendChild(container);
|
||
|
||
// Initialize ViewerJS
|
||
const viewer = new Viewer(container, {
|
||
inline: false,
|
||
button: true,
|
||
backdrop: true,
|
||
navbar: true,
|
||
toolbar: {
|
||
zoomIn: 1,
|
||
zoomOut: 1,
|
||
oneToOne: 1,
|
||
reset: 1,
|
||
prev: 1,
|
||
play: false,
|
||
next: 1,
|
||
rotateLeft: 1,
|
||
rotateRight: 1,
|
||
flipHorizontal: 1,
|
||
flipVertical: 1
|
||
},
|
||
title: [1, (image, imageData) => image.alt || 'Image'],
|
||
shown: () => {
|
||
this.addMetadataSidebar();
|
||
},
|
||
viewed: (event) => {
|
||
this.loadMetadataForImage(event.detail.image);
|
||
},
|
||
hide: () => this.invalidateMetadataRequest(),
|
||
hidden: () => {
|
||
const sidebar = document.getElementById('metadata-sidebar');
|
||
if (sidebar) sidebar.remove();
|
||
viewer.destroy();
|
||
container.remove();
|
||
},
|
||
initialViewIndex: startIndex
|
||
});
|
||
|
||
viewer.show();
|
||
}
|
||
|
||
formatFilmStripCaption(image) {
|
||
const parts = [];
|
||
if (image.filename) parts.push(image.filename);
|
||
if (image.width && image.height) parts.push(`${image.width}x${image.height}`);
|
||
return parts.join(' | ') || 'Image';
|
||
}
|
||
|
||
async renderFilmStripForPrompt(promptId, container) {
|
||
const images = await this.loadFilmStripImages(promptId);
|
||
container.innerHTML = this.createFilmStrip(images, promptId);
|
||
}
|
||
|
||
// ==================== End Film Strip Functions ====================
|
||
|
||
async showAutoTagModal() {
|
||
this.showModal("autoTagModal");
|
||
await this.checkAutoTagModels();
|
||
}
|
||
|
||
_setModelStatus(el, downloaded, modelKey) {
|
||
el.textContent = '';
|
||
if (downloaded) {
|
||
const span = document.createElement('span');
|
||
span.className = 'text-pm-success text-sm';
|
||
span.textContent = '\u2713 Downloaded';
|
||
el.appendChild(span);
|
||
} else {
|
||
// Download lives in System › Auto-tagging (#330); the batch refuses a model that is not downloaded.
|
||
const link = document.createElement('a');
|
||
link.href = 'workspace#/system/tagging';
|
||
link.target = '_blank';
|
||
link.rel = 'noopener';
|
||
link.title = 'Opens System › Auto-tagging in a new tab';
|
||
link.className = 'px-3 py-1 bg-pm-accent hover:bg-pm-accent-hover text-pm text-xs rounded transition-colors';
|
||
link.textContent = 'Manage in System';
|
||
el.appendChild(link);
|
||
}
|
||
}
|
||
|
||
async checkAutoTagModels() {
|
||
const ggufStatus = document.getElementById("ggufModelStatus");
|
||
const hfStatus = document.getElementById("hfModelStatus");
|
||
const wd14SwinV2Status = document.getElementById("wd14SwinV2ModelStatus");
|
||
const wd14VitStatus = document.getElementById("wd14VitModelStatus");
|
||
|
||
const statusEls = [ggufStatus, hfStatus, wd14SwinV2Status, wd14VitStatus];
|
||
statusEls.forEach(el => { el.textContent = 'Checking...'; el.className = 'flex items-center gap-1.5'; });
|
||
|
||
try {
|
||
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/models');
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
this._setModelStatus(ggufStatus, data.models.gguf.downloaded, 'gguf');
|
||
this._setModelStatus(hfStatus, data.models.hf.downloaded, 'hf');
|
||
this._setModelStatus(wd14SwinV2Status, data.models['wd14-swinv2']?.downloaded, 'wd14-swinv2');
|
||
this._setModelStatus(wd14VitStatus, data.models['wd14-vit']?.downloaded, 'wd14-vit');
|
||
|
||
// Update WD14 threshold defaults from server
|
||
if (data.wd14_general_threshold !== undefined) {
|
||
const genSlider = document.getElementById('wd14GeneralThreshold');
|
||
genSlider.value = data.wd14_general_threshold;
|
||
document.getElementById('wd14GeneralThresholdValue').textContent = parseFloat(data.wd14_general_threshold).toFixed(2);
|
||
}
|
||
if (data.wd14_character_threshold !== undefined) {
|
||
const charSlider = document.getElementById('wd14CharacterThreshold');
|
||
charSlider.value = data.wd14_character_threshold;
|
||
document.getElementById('wd14CharacterThresholdValue').textContent = parseFloat(data.wd14_character_threshold).toFixed(2);
|
||
}
|
||
} else {
|
||
statusEls.forEach(el => { el.textContent = 'Error'; });
|
||
}
|
||
} catch (error) {
|
||
console.error('Error checking models:', error);
|
||
statusEls.forEach(el => { el.textContent = 'Error'; });
|
||
}
|
||
}
|
||
|
||
async startAutoTag() {
|
||
const modelType = document.querySelector('input[name="autoTagModel"]:checked').value;
|
||
const prompt = document.getElementById('autoTagPrompt').value;
|
||
const skipTagged = document.querySelector('input[name="autoTagMode"]:checked').value === 'skip';
|
||
|
||
// Check if model is downloaded
|
||
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/models');
|
||
const data = await response.json();
|
||
|
||
if (data.success && !data.models[modelType].downloaded) {
|
||
this.showNotification('Please download the selected model first', 'warning');
|
||
return;
|
||
}
|
||
|
||
this.hideModal("autoTagModal");
|
||
this.showModal("autoTagProgressModal");
|
||
|
||
// Reset progress display
|
||
document.getElementById('autoTagCurrentFile').textContent = 'Loading model...';
|
||
document.getElementById('autoTagProgressPercent').textContent = '0%';
|
||
document.getElementById('autoTagProgressBar').style.width = '0%';
|
||
document.getElementById('autoTagProcessed').textContent = '0';
|
||
document.getElementById('autoTagApplied').textContent = '0';
|
||
document.getElementById('autoTagSkipped').textContent = '0';
|
||
|
||
this.autoTagState.cancelled = false;
|
||
|
||
try {
|
||
const formData = new URLSearchParams();
|
||
formData.append('model_type', modelType);
|
||
formData.append('skip_tagged', skipTagged ? 'true' : 'false');
|
||
|
||
if (modelType.startsWith('wd14')) {
|
||
formData.append('general_threshold', document.getElementById('wd14GeneralThreshold').value);
|
||
formData.append('character_threshold', document.getElementById('wd14CharacterThreshold').value);
|
||
} else {
|
||
formData.append('prompt', prompt);
|
||
}
|
||
|
||
this.autoTagState.eventSource = SseStream.connect(`/prompt_manager/autotag/start?${formData.toString()}`, { method: 'POST' });
|
||
|
||
this.autoTagState.eventSource.onmessage = (event) => {
|
||
const data = JSON.parse(event.data);
|
||
|
||
if (data.type === 'progress') {
|
||
document.getElementById('autoTagCurrentFile').textContent = data.status || 'Processing...';
|
||
document.getElementById('autoTagProgressPercent').textContent = `${data.progress || 0}%`;
|
||
document.getElementById('autoTagProgressBar').style.width = `${data.progress || 0}%`;
|
||
if (data.processed !== undefined) {
|
||
document.getElementById('autoTagProcessed').textContent = data.processed;
|
||
document.getElementById('autoTagApplied').textContent = data.tagged || 0;
|
||
document.getElementById('autoTagSkipped').textContent = data.skipped || 0;
|
||
}
|
||
} else if (data.type === 'complete') {
|
||
this.autoTagState.eventSource.close();
|
||
this.hideModal("autoTagProgressModal");
|
||
this.showNotification(ImageHelpers.autotagCompleteMessage(data.tagged), 'success');
|
||
this.refreshList(); // Refresh the prompt list
|
||
} else if (data.type === 'error') {
|
||
this.autoTagState.eventSource.close();
|
||
this.hideModal("autoTagProgressModal");
|
||
this.showNotification(`Auto tag error: ${data.message}`, 'error');
|
||
} else if (data.type === 'cancelled') {
|
||
this.autoTagState.eventSource.close();
|
||
this.hideModal("autoTagProgressModal");
|
||
this.showNotification('Auto tagging cancelled', 'info');
|
||
}
|
||
};
|
||
|
||
this.autoTagState.eventSource.onerror = (error) => {
|
||
console.error('AutoTag SSE error:', error);
|
||
console.error('EventSource readyState:', this.autoTagState.eventSource.readyState);
|
||
this.autoTagState.eventSource.close();
|
||
this.hideModal("autoTagProgressModal");
|
||
this.showNotification((error && error.serverMessage) || 'Connection lost during auto tagging', 'error');
|
||
};
|
||
} catch (error) {
|
||
console.error('Auto tag error:', error);
|
||
this.hideModal("autoTagProgressModal");
|
||
this.showNotification('Failed to start auto tagging', 'error');
|
||
}
|
||
}
|
||
|
||
cancelAutoTag() {
|
||
this.autoTagState.cancelled = true;
|
||
if (this.autoTagState.eventSource) {
|
||
this.autoTagState.eventSource.close();
|
||
this.autoTagState.eventSource = null;
|
||
}
|
||
this.hideModal("autoTagProgressModal");
|
||
this.showNotification('Auto tagging cancelled', 'info');
|
||
}
|
||
|
||
async startReview() {
|
||
const modelType = document.querySelector('input[name="autoTagModel"]:checked').value;
|
||
|
||
// Check if model is downloaded
|
||
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/models');
|
||
const data = await response.json();
|
||
|
||
if (data.success && !data.models[modelType].downloaded) {
|
||
this.showNotification('Please download the selected model first', 'warning');
|
||
return;
|
||
}
|
||
|
||
this.hideModal("autoTagModal");
|
||
|
||
// Show loading modal while we fetch images and load model
|
||
this.showModal("autoTagLoadingModal");
|
||
document.getElementById('autoTagLoadingStatus').textContent = 'Fetching images from database...';
|
||
|
||
// Get ALL images with linked prompts from the database
|
||
try {
|
||
const scanData = { images: await this.fetchAllImages() };
|
||
|
||
if (scanData.images.length === 0) {
|
||
this.hideModal("autoTagLoadingModal");
|
||
this.showNotification('No images with linked prompts found in database', 'warning');
|
||
return;
|
||
}
|
||
|
||
this.autoTagState.reviewImages = scanData.images;
|
||
this.autoTagState.reviewIndex = 0;
|
||
this.autoTagState.modelType = modelType;
|
||
this.autoTagState.prompt = document.getElementById('autoTagPrompt').value;
|
||
this.autoTagState.generalThreshold = parseFloat(document.getElementById('wd14GeneralThreshold').value);
|
||
this.autoTagState.characterThreshold = parseFloat(document.getElementById('wd14CharacterThreshold').value);
|
||
this.autoTagState.modelLoaded = false;
|
||
this.autoTagState.skipAllTagged = false; // Reset skip flag for new session
|
||
|
||
document.getElementById('reviewTotalCount').textContent = scanData.images.length;
|
||
|
||
// Update loading status - model will load on first image
|
||
document.getElementById('autoTagLoadingStatus').textContent =
|
||
`Found ${scanData.images.length} images. Loading ${modelType.toUpperCase()} model...`;
|
||
|
||
// Load first image (this will load the model)
|
||
await this.loadNextReviewImage();
|
||
|
||
// Hide loading, show review modal
|
||
this.hideModal("autoTagLoadingModal");
|
||
this.showModal("autoTagReviewModal");
|
||
} catch (error) {
|
||
console.error('Error starting review:', error);
|
||
this.hideModal("autoTagLoadingModal");
|
||
this.showNotification('Failed to start review mode', 'error');
|
||
}
|
||
}
|
||
|
||
/** POST /autotag/single for `image` (#379: `existing` "skip" answers already_tagged for an auto-tagged image). */
|
||
async requestReviewTags(image, existing) {
|
||
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/single', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(ImageHelpers.autotagSingleBody(image, { ...this.autoTagState, existing }))
|
||
});
|
||
return response.json();
|
||
}
|
||
|
||
// Show the re-tag confirmation modal
|
||
async showRetagConfirmation(image, imageUrl, realTags) {
|
||
return new Promise((resolve) => {
|
||
this.autoTagState.retagResolve = resolve;
|
||
|
||
// Set up the modal content
|
||
document.getElementById('retagPreviewImage').src = imageUrl;
|
||
document.getElementById('retagImageName').textContent = image.filename || String(image.image_path || '').split(/[\\/]/).pop();
|
||
|
||
// Display existing tags
|
||
const tagsContainer = document.getElementById('retagExistingTags');
|
||
tagsContainer.innerHTML = realTags.map(tag =>
|
||
`<span class="px-2 py-1 bg-pm-accent text-pm text-xs rounded">${escapeHtml(tag)}</span>`
|
||
).join('');
|
||
|
||
// Show modal
|
||
this.showModal('retagConfirmModal');
|
||
});
|
||
}
|
||
|
||
// Handle user's choice in re-tag modal
|
||
handleRetagChoice(choice) {
|
||
this.hideModal('retagConfirmModal');
|
||
|
||
if (choice === 'skipAll') {
|
||
this.autoTagState.skipAllTagged = true;
|
||
}
|
||
|
||
if (this.autoTagState.retagResolve) {
|
||
this.autoTagState.retagResolve(choice);
|
||
this.autoTagState.retagResolve = null;
|
||
}
|
||
}
|
||
|
||
async loadNextReviewImage() {
|
||
if (this.autoTagState.reviewIndex >= this.autoTagState.reviewImages.length) {
|
||
this.hideModal("autoTagReviewModal");
|
||
this.showNotification('Review complete!', 'success');
|
||
this.refreshList();
|
||
return;
|
||
}
|
||
|
||
const image = this.autoTagState.reviewImages[this.autoTagState.reviewIndex];
|
||
document.getElementById('reviewCurrentIndex').textContent = this.autoTagState.reviewIndex + 1;
|
||
|
||
// The API describes images relative to the ComfyUI tree and
|
||
// always provides a servable url; never rebuild it from the path.
|
||
const imageUrl = this.getImageUrl(image);
|
||
|
||
this.autoTagState.currentImage = image;
|
||
this.autoTagState.currentSha256 = null;
|
||
this.autoTagState.tagsExpanded = false; // Reset accordion for new image
|
||
|
||
let data;
|
||
try {
|
||
// Each image is first asked with "skip": an image a tagging model already tagged answers
|
||
// already_tagged (its image tags, never its prompt's) without running the model (#379).
|
||
data = await this.requestReviewTags(image, 'skip');
|
||
if (data.success && data.already_tagged) {
|
||
if (this.autoTagState.skipAllTagged) {
|
||
this.autoTagState.reviewIndex++;
|
||
return this.loadNextReviewImage();
|
||
}
|
||
const choice = await this.showRetagConfirmation(image, imageUrl, data.image_tags || []);
|
||
if (choice === 'skip' || choice === 'skipAll') {
|
||
this.autoTagState.reviewIndex++;
|
||
return this.loadNextReviewImage();
|
||
}
|
||
// choice === 'retag': generate new tags for it
|
||
this.showReviewImage(image, imageUrl);
|
||
data = await this.requestReviewTags(image, 'merge');
|
||
} else {
|
||
this.showReviewImage(image, imageUrl);
|
||
}
|
||
if (data.success) {
|
||
this.autoTagState.currentTags = data.tags;
|
||
this.autoTagState.currentSha256 = data.sha256 || null;
|
||
this.renderReviewTags();
|
||
} else {
|
||
document.getElementById('reviewTagsVisible').innerHTML =
|
||
`<div class="text-pm-error">Error: ${escapeHtml(data.error)}</div>`;
|
||
}
|
||
} catch (error) {
|
||
console.error('Error generating tags:', error);
|
||
this.showReviewImage(image, imageUrl);
|
||
document.getElementById('reviewTagsVisible').innerHTML =
|
||
'<div class="text-pm-error">Failed to generate tags</div>';
|
||
}
|
||
}
|
||
|
||
showReviewImage(image, imageUrl) {
|
||
document.getElementById('reviewImage').src = imageUrl;
|
||
// The record's file name, else the last segment of its path (#328: `filename` was never declared).
|
||
const filename = image.filename || String(image.image_path || image.path || '').split(/[\\/]/).pop();
|
||
document.getElementById('reviewImageName').textContent = filename || 'Unknown';
|
||
document.getElementById('reviewTagsVisible').innerHTML = '<div class="text-pm-secondary">Generating tags...</div>';
|
||
document.getElementById('reviewTagsHidden').innerHTML = '';
|
||
document.getElementById('reviewTagsAccordion').classList.add('hidden');
|
||
document.getElementById('reviewTagsToggle').classList.add('hidden');
|
||
}
|
||
|
||
/** /prompt_manager/images/all is capped per call; collect every page. */
|
||
async fetchAllImages() {
|
||
const images = [];
|
||
let offset = 0;
|
||
for (;;) {
|
||
const data = await this.api.get(`/prompt_manager/images/all?offset=${offset}`);
|
||
const page = Array.isArray(data.images) ? data.images : [];
|
||
images.push(...page);
|
||
if (!ListState.hasMorePages(data, page.length)) return images;
|
||
offset += page.length;
|
||
}
|
||
}
|
||
|
||
renderReviewTags() {
|
||
const visibleContainer = document.getElementById('reviewTagsVisible');
|
||
const hiddenContainer = document.getElementById('reviewTagsHidden');
|
||
const accordion = document.getElementById('reviewTagsAccordion');
|
||
const toggleBtn = document.getElementById('reviewTagsToggle');
|
||
const toggleIcon = document.getElementById('reviewTagsToggleIcon');
|
||
const toggleText = document.getElementById('reviewTagsToggleText');
|
||
const countSpan = document.getElementById('reviewTagsCount');
|
||
|
||
if (this.autoTagState.currentTags.length === 0) {
|
||
visibleContainer.innerHTML = '<div class="text-pm-secondary">No tags generated</div>';
|
||
hiddenContainer.innerHTML = '';
|
||
accordion.classList.add('hidden');
|
||
toggleBtn.classList.add('hidden');
|
||
return;
|
||
}
|
||
|
||
// Show first ~10 tags in visible row, rest in accordion
|
||
const visibleCount = Math.min(10, this.autoTagState.currentTags.length);
|
||
const visibleTags = this.autoTagState.currentTags.slice(0, visibleCount);
|
||
const hiddenTags = this.autoTagState.currentTags.slice(visibleCount);
|
||
|
||
const createTagChip = (tag, index) => `
|
||
<span class="tag-chip">
|
||
${escapeHtml(tag)}
|
||
<span class="tag-remove" data-action="remove-review-tag" data-index="${index}">×</span>
|
||
</span>
|
||
`;
|
||
|
||
visibleContainer.innerHTML = visibleTags.map((tag, i) => createTagChip(tag, i)).join('');
|
||
|
||
if (hiddenTags.length > 0) {
|
||
hiddenContainer.innerHTML = hiddenTags.map((tag, i) => createTagChip(tag, visibleCount + i)).join('');
|
||
toggleBtn.classList.remove('hidden');
|
||
countSpan.textContent = `(${hiddenTags.length} more)`;
|
||
|
||
// Reset accordion state
|
||
if (!this.autoTagState.tagsExpanded) {
|
||
accordion.classList.add('hidden');
|
||
toggleIcon.textContent = '▼';
|
||
toggleText.textContent = 'Show all tags';
|
||
}
|
||
} else {
|
||
hiddenContainer.innerHTML = '';
|
||
accordion.classList.add('hidden');
|
||
toggleBtn.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
toggleReviewTags() {
|
||
const accordion = document.getElementById('reviewTagsAccordion');
|
||
const toggleIcon = document.getElementById('reviewTagsToggleIcon');
|
||
const toggleText = document.getElementById('reviewTagsToggleText');
|
||
|
||
this.autoTagState.tagsExpanded = !this.autoTagState.tagsExpanded;
|
||
|
||
if (this.autoTagState.tagsExpanded) {
|
||
accordion.classList.remove('hidden');
|
||
toggleIcon.textContent = '▲';
|
||
toggleText.textContent = 'Show less';
|
||
} else {
|
||
accordion.classList.add('hidden');
|
||
toggleIcon.textContent = '▼';
|
||
toggleText.textContent = 'Show all tags';
|
||
}
|
||
}
|
||
|
||
toggleMainTags(promptId) {
|
||
const container = document.querySelector(`.tags-accordion[data-prompt-id="${promptId}"]`);
|
||
if (!container) return;
|
||
|
||
const hiddenSection = container.querySelector('.tags-hidden');
|
||
const toggleBtn = container.querySelector('.tags-toggle-btn');
|
||
if (!hiddenSection || !toggleBtn) return;
|
||
|
||
const toggleIcon = toggleBtn.querySelector('.toggle-icon');
|
||
const toggleText = toggleBtn.querySelector('.toggle-text');
|
||
const isHidden = hiddenSection.classList.contains('hidden');
|
||
|
||
if (isHidden) {
|
||
hiddenSection.classList.remove('hidden');
|
||
toggleIcon.textContent = '▲';
|
||
toggleText.textContent = 'Show less';
|
||
} else {
|
||
hiddenSection.classList.add('hidden');
|
||
toggleIcon.textContent = '▼';
|
||
// Recalculate the count from the hidden tags
|
||
const hiddenTags = hiddenSection.querySelectorAll('span.inline-flex').length;
|
||
toggleText.textContent = `Show ${hiddenTags} more tags`;
|
||
}
|
||
}
|
||
|
||
removeReviewTag(index) {
|
||
this.autoTagState.currentTags.splice(index, 1);
|
||
this.renderReviewTags();
|
||
}
|
||
|
||
skipReviewImage() {
|
||
this.autoTagState.reviewIndex++;
|
||
this.loadNextReviewImage();
|
||
}
|
||
|
||
async applyReviewTags() {
|
||
// #379: the reviewed tags go to this image (linked or not), never its prompt; an empty
|
||
// list still marks the image as reviewed by a tagging model.
|
||
if (!this.autoTagState.currentSha256) {
|
||
this.skipReviewImage();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/apply', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(ImageHelpers.autotagApplyBody(
|
||
this.autoTagState.currentImage, this.autoTagState.currentSha256, this.autoTagState.currentTags))
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
this.showNotification(`Applied ${this.autoTagState.currentTags.length} tags`, 'success');
|
||
} else {
|
||
this.showNotification(data.error || 'Failed to apply tags', 'warning');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error applying tags:', error);
|
||
this.showNotification('Failed to apply tags', 'error');
|
||
}
|
||
|
||
this.autoTagState.reviewIndex++;
|
||
this.loadNextReviewImage();
|
||
}
|
||
|
||
cancelReview() {
|
||
this.autoTagState.reviewImages = [];
|
||
this.autoTagState.reviewIndex = 0;
|
||
this.autoTagState.currentTags = [];
|
||
this.autoTagState.skipAllTagged = false;
|
||
this.hideModal("autoTagReviewModal");
|
||
}
|
||
}
|
||
|
||
/** Build the dashboard; needs the DOM, so it runs on DOMContentLoaded. */
|
||
function startAdmin() {
|
||
// Initialize the admin interface
|
||
const admin = new PromptAdmin();
|
||
window.admin = admin;
|
||
|
||
// Add event listeners for prompt selection
|
||
document.addEventListener("change", function (e) {
|
||
if (e.target.classList.contains("prompt-checkbox")) {
|
||
const promptId = parseInt(e.target.dataset.id);
|
||
if (e.target.checked) {
|
||
admin.selectedPrompts.add(promptId);
|
||
} else {
|
||
admin.selectedPrompts.delete(promptId);
|
||
}
|
||
admin.updateBulkActionButtons();
|
||
|
||
const allCheckboxes = document.querySelectorAll(".prompt-checkbox");
|
||
const checkedCheckboxes = document.querySelectorAll(".prompt-checkbox:checked");
|
||
const selectAllCheckbox = document.getElementById("selectAll");
|
||
selectAllCheckbox.checked = allCheckboxes.length === checkedCheckboxes.length;
|
||
selectAllCheckbox.indeterminate = checkedCheckboxes.length > 0 && checkedCheckboxes.length < allCheckboxes.length;
|
||
}
|
||
});
|
||
|
||
// Escape closes the gallery modal
|
||
document.addEventListener('keydown', function (e) {
|
||
const galleryModal = document.getElementById('galleryModal');
|
||
if (e.key === 'Escape' && galleryModal && !galleryModal.classList.contains('hidden')) {
|
||
admin.closeGallery();
|
||
}
|
||
});
|
||
}
|
||
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", startAdmin);
|
||
} else {
|
||
startAdmin();
|
||
}
|