Merge feature/next: videos in the gallery (PR #21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # ARCHITECTURE.md # docs/architecture/frontend.md # web/modules/ui_settings_page.js
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* What the output gallery lists: everything, pictures or videos (the switch beside its search),
|
||||
* minus the formats hidden in Settings. Both are this browser's own (localStorage). A format is
|
||||
* stored when hidden, so one added later shows. The server's side is GET
|
||||
* /anomalous/gallery_images?media=..&hide=.. (api/gallery_routes.py).
|
||||
*/
|
||||
|
||||
export const GALLERY_IMAGE_FORMATS = ['png', 'jpg', 'webp', 'gif', 'avif']; // jpg covers .jpeg
|
||||
export const GALLERY_VIDEO_FORMATS = ['mp4', 'webm', 'mov', 'mkv'];
|
||||
export const GALLERY_MEDIA = ['all', 'image', 'video'];
|
||||
|
||||
const HIDDEN_KEY = 'anomalous_gallery_hidden_formats';
|
||||
const MEDIA_KEY = 'anomalous_gallery_media';
|
||||
|
||||
function read(key) {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function write(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Storage blocked (private window): the choice lasts until the page reloads.
|
||||
}
|
||||
}
|
||||
|
||||
/** An output entry ({ filename, media_type } from the listing) is a video. */
|
||||
export function isGalleryVideo(item) {
|
||||
if (item?.media_type) return item.media_type === 'video';
|
||||
return GALLERY_VIDEO_FORMATS.includes(String(item?.filename || '').split('.').pop().toLowerCase());
|
||||
}
|
||||
|
||||
export function hiddenGalleryFormats() {
|
||||
try {
|
||||
const list = JSON.parse(read(HIDDEN_KEY) || '[]');
|
||||
return new Set(Array.isArray(list) ? list.filter(format => typeof format === 'string') : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export function setGalleryFormatHidden(format, hidden) {
|
||||
const formats = hiddenGalleryFormats();
|
||||
if (hidden) formats.add(format);
|
||||
else formats.delete(format);
|
||||
write(HIDDEN_KEY, JSON.stringify([...formats]));
|
||||
}
|
||||
|
||||
export function galleryMedia() {
|
||||
const media = read(MEDIA_KEY);
|
||||
return GALLERY_MEDIA.includes(media) ? media : 'all';
|
||||
}
|
||||
|
||||
export function setGalleryMedia(media) {
|
||||
write(MEDIA_KEY, media);
|
||||
}
|
||||
|
||||
/** Adds the gallery's filter to a listing request. */
|
||||
export function appendGalleryFilter(params) {
|
||||
params.set('media', galleryMedia());
|
||||
const hidden = [...hiddenGalleryFormats()];
|
||||
if (hidden.length) params.set('hide', hidden.join(','));
|
||||
}
|
||||
@@ -689,6 +689,15 @@ export const i18n = {
|
||||
gallerySearchCount: '找到 {count} 张',
|
||||
gallerySearchEmpty: '没有找到匹配的图片',
|
||||
gallerySearching: '正在搜索…',
|
||||
galleryFilterAll: '全部',
|
||||
galleryFilterImages: '图片',
|
||||
galleryFilterVideos: '视频',
|
||||
galleryFormatsTitle: '显示的格式',
|
||||
galleryFormatsHelp: '点一下某个格式,图库里就不再显示它,再点一下恢复。视频能否播放取决于浏览器,MP4 和 WebM 最稳。',
|
||||
galleryFormatShown: '显示中,点一下隐藏',
|
||||
galleryFormatHidden: '已隐藏,点一下显示',
|
||||
galleryViewVideo: '播放视频',
|
||||
settingsGallery: '图库',
|
||||
galleryClose: '关闭图库',
|
||||
galleryHistoryTitle: '历史生成图库: {name}',
|
||||
galleryScanning: '加载中,正在扫描图片元数据...',
|
||||
@@ -3052,6 +3061,15 @@ export const i18n = {
|
||||
gallerySearchCount: '{count} found',
|
||||
gallerySearchEmpty: 'No matching images',
|
||||
gallerySearching: 'Searching…',
|
||||
galleryFilterAll: 'All',
|
||||
galleryFilterImages: 'Images',
|
||||
galleryFilterVideos: 'Videos',
|
||||
galleryFormatsTitle: 'Formats shown',
|
||||
galleryFormatsHelp: 'Click a format to hide it from the gallery, and again to show it. Whether a video plays depends on the browser; MP4 and WebM are the safest.',
|
||||
galleryFormatShown: 'Shown; click to hide',
|
||||
galleryFormatHidden: 'Hidden; click to show',
|
||||
galleryViewVideo: 'Play video',
|
||||
settingsGallery: 'Gallery',
|
||||
galleryClose: 'Close Gallery',
|
||||
galleryHistoryTitle: 'Generated History: {name}',
|
||||
galleryScanning: 'Loading, scanning metadata...',
|
||||
|
||||
+49
-16
@@ -10,6 +10,8 @@ import { showImageWorkbench } from './ui_gallery_detail.js';
|
||||
import { createGalleryCard } from './ui_gallery_card.js';
|
||||
import { loadKeptImages } from './image_keep.js';
|
||||
import { createSearchChips } from './ui_search_chips.js';
|
||||
import { createGalleryMediaSwitch } from './ui_gallery_filter.js';
|
||||
import { appendGalleryFilter } from './gallery_filter.js';
|
||||
|
||||
const t = (key, params) => translate(key, params);
|
||||
|
||||
@@ -29,8 +31,12 @@ export async function refreshGalleryImages() {
|
||||
|
||||
|
||||
|
||||
/** Search blocks above the output gallery; the terms live on the browser as `gallerySearchTerms`. */
|
||||
/** Search blocks above the output gallery (the terms live on the browser as `gallerySearchTerms`),
|
||||
* with All / Pictures / Videos beside them. */
|
||||
export function createGallerySearchBar(owner) {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'anomalous-gallery-header';
|
||||
|
||||
const search = createSearchChips({
|
||||
placeholder: t('gallerySearchPlaceholder'),
|
||||
help: t('gallerySearchHelp'),
|
||||
@@ -41,7 +47,10 @@ export function createGallerySearchBar(owner) {
|
||||
});
|
||||
search.element.classList.add('anomalous-gallery-search');
|
||||
owner.gallerySearchCount = search.countElement;
|
||||
return search.element;
|
||||
|
||||
owner.galleryMediaSwitch = createGalleryMediaSwitch(() => owner.loadGalleryImages(1, true, { refresh: false }));
|
||||
header.append(search.element, owner.galleryMediaSwitch);
|
||||
return header;
|
||||
}
|
||||
|
||||
export async function loadGalleryImages(page = 1, reset = false, { refresh = reset } = {}) {
|
||||
@@ -60,12 +69,14 @@ export async function loadGalleryImages(page = 1, reset = false, { refresh = res
|
||||
const params = new URLSearchParams({ page: String(page), limit: '50' });
|
||||
if (refresh) params.set('refresh', '1');
|
||||
terms.forEach(term => params.append('term', term));
|
||||
appendGalleryFilter(params);
|
||||
const res = await fetch(`/anomalous/gallery_images?${params}`);
|
||||
const data = await res.json();
|
||||
if (termsKey !== JSON.stringify(this.gallerySearchTerms || [])) {
|
||||
this.galleryReloadPending = true;
|
||||
return;
|
||||
}
|
||||
this.galleryMediaSwitch?.refresh(Boolean(data.has_videos));
|
||||
if (this.gallerySearchCount) {
|
||||
this.gallerySearchCount.textContent = query && Number.isFinite(data.total) ? t('gallerySearchCount', { count: data.total }) : '';
|
||||
}
|
||||
@@ -87,6 +98,7 @@ export async function loadGalleryImages(page = 1, reset = false, { refresh = res
|
||||
return {
|
||||
filename: imgData.filename,
|
||||
subfolder: imgData.subfolder || '',
|
||||
media_type: imgData.media_type,
|
||||
url: `/view?filename=${q_file}&subfolder=${q_sub}&type=output`,
|
||||
sourceImage: {
|
||||
type: 'output',
|
||||
@@ -433,7 +445,7 @@ export function showGallerySelectMode(model) {
|
||||
|
||||
|
||||
|
||||
export function showGalleryViewer(src) {
|
||||
export function showGalleryViewer(src, isVideo = false) {
|
||||
let viewer = document.getElementById('anomalous-gallery-viewer');
|
||||
if (!viewer) {
|
||||
viewer = document.createElement('dialog');
|
||||
@@ -447,7 +459,15 @@ export function showGalleryViewer(src) {
|
||||
const img = document.createElement('img');
|
||||
img.id = 'anomalous-gallery-viewer-img';
|
||||
|
||||
const video = document.createElement('video');
|
||||
video.id = 'anomalous-gallery-viewer-video';
|
||||
video.controls = true;
|
||||
video.autoplay = true;
|
||||
video.loop = true;
|
||||
video.style.display = 'none';
|
||||
|
||||
viewer.appendChild(img);
|
||||
viewer.appendChild(video);
|
||||
viewer.appendChild(closeBtn);
|
||||
|
||||
let scale = 1;
|
||||
@@ -456,22 +476,26 @@ export function showGalleryViewer(src) {
|
||||
let isDragging = false;
|
||||
let startX = 0, startY = 0;
|
||||
|
||||
const resetImgTransform = () => {
|
||||
const resetTransform = () => {
|
||||
scale = 1; translateX = 0; translateY = 0;
|
||||
img.style.transform = `translate(0px, 0px) scale(1)`;
|
||||
img.style.cursor = 'grab';
|
||||
};
|
||||
|
||||
closeBtn.onclick = () => {
|
||||
viewer.close();
|
||||
resetImgTransform();
|
||||
viewer.stopVideo = () => {
|
||||
video.pause();
|
||||
video.style.display = 'none';
|
||||
video.removeAttribute('src');
|
||||
video.load(); // drops the file, so its sound cannot go on
|
||||
};
|
||||
|
||||
// However it closes (the ×, a click beside the picture, Esc), the video stops.
|
||||
viewer.addEventListener('close', () => {
|
||||
resetTransform();
|
||||
viewer.stopVideo();
|
||||
});
|
||||
closeBtn.onclick = () => viewer.close();
|
||||
viewer.onclick = (e) => {
|
||||
if (e.target === viewer) {
|
||||
viewer.close();
|
||||
resetImgTransform();
|
||||
}
|
||||
if (e.target === viewer) viewer.close();
|
||||
};
|
||||
|
||||
viewer.addEventListener('wheel', (e) => {
|
||||
@@ -506,11 +530,20 @@ export function showGalleryViewer(src) {
|
||||
}
|
||||
|
||||
const img = document.getElementById('anomalous-gallery-viewer-img');
|
||||
img.src = src;
|
||||
const video = document.getElementById('anomalous-gallery-viewer-video');
|
||||
|
||||
// Reset scale and translation when opening a new image
|
||||
img.style.transform = `translate(0px, 0px) scale(1)`;
|
||||
img.style.cursor = 'grab';
|
||||
if (isVideo) {
|
||||
img.style.display = 'none';
|
||||
video.style.display = 'block';
|
||||
video.src = src;
|
||||
video.play().catch(() => {});
|
||||
} else {
|
||||
viewer.stopVideo();
|
||||
img.style.display = 'block';
|
||||
img.src = src;
|
||||
img.style.transform = `translate(0px, 0px) scale(1)`;
|
||||
img.style.cursor = 'grab';
|
||||
}
|
||||
|
||||
if (!viewer.open) viewer.showModal();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* One output image's card in the gallery: the picture (opens the viewer, or becomes a
|
||||
* One output's card in the gallery: the picture (opens the viewer, or becomes a
|
||||
* model's cover while one is being chosen; drags onto the canvas), Parameters (the image
|
||||
* workbench), the star (ui_keep_menu.js: keep it as a workflow, a combo or its prompts;
|
||||
* filled once kept as anything), Delete (to the Recycle Bin, after a confirmation) and the
|
||||
* right-click menu (gallery_card_menu.js: insert as a Load Image node, parameters).
|
||||
* right-click menu (gallery_card_menu.js: insert as a Load Image node, parameters). A video's
|
||||
* card shows its first frame with a ▶ badge and plays in the viewer; it has no Parameters,
|
||||
* star or menu (those read a picture).
|
||||
*/
|
||||
|
||||
import { translate as t } from './locales.js';
|
||||
@@ -12,8 +14,10 @@ import { showImageWorkbench } from './ui_gallery_detail.js';
|
||||
import { openKeepMenu } from './ui_keep_menu.js';
|
||||
import { keptKey } from './image_keep.js';
|
||||
import { openGalleryCardMenu } from './gallery_card_menu.js';
|
||||
import { isGalleryVideo } from './gallery_filter.js';
|
||||
|
||||
const DETAILS_ICON = `<svg class="anomalous-gallery-details-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><line x1="2" y1="5" x2="8" y2="5"></line><line x1="12" y1="5" x2="14" y2="5"></line><circle cx="10" cy="5" r="2"></circle><line x1="2" y1="11" x2="4" y2="11"></line><line x1="8" y1="11" x2="14" y2="11"></line><circle cx="6" cy="11" r="2"></circle></svg>`;
|
||||
const PLAY_ICON = '<svg viewBox="0 0 24 24" width="10" height="10" fill="currentColor" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"/></svg>';
|
||||
const DELETE_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>';
|
||||
|
||||
function button(className, title, onClick) {
|
||||
@@ -67,14 +71,16 @@ async function useAsCover(owner, image) {
|
||||
}
|
||||
|
||||
function openWorkbench(owner, image, url) {
|
||||
const list = owner.galleryImagesList || [];
|
||||
// The workbench steps through pictures only (it reads their parameters).
|
||||
const pictures = () => (owner.galleryImagesList || []).filter(item => !isGalleryVideo(item));
|
||||
const list = pictures();
|
||||
const index = list.findIndex(item => sameItem(item, image));
|
||||
void showImageWorkbench(owner, image, url, {
|
||||
items: list,
|
||||
currentIndex: Math.max(0, index),
|
||||
loadMore: async () => {
|
||||
if (owner.galleryHasMore && !owner.galleryLoading) await owner.loadGalleryImages(owner.galleryCurrentPage + 1);
|
||||
return owner.galleryImagesList || [];
|
||||
return pictures();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -116,7 +122,7 @@ function keepButton(owner, image) {
|
||||
return star;
|
||||
}
|
||||
|
||||
/** The card of `imgData` ({ filename, subfolder } from GET /anomalous/gallery_images). */
|
||||
/** The card of `imgData` ({ filename, subfolder, media_type } from GET /anomalous/gallery_images). */
|
||||
export function createGalleryCard(owner, imgData, { showViewer }) {
|
||||
const image = { type: 'output', filename: imgData.filename, subfolder: imgData.subfolder || '' };
|
||||
const query = `filename=${encodeURIComponent(image.filename)}&subfolder=${encodeURIComponent(image.subfolder)}`;
|
||||
@@ -124,10 +130,13 @@ export function createGalleryCard(owner, imgData, { showViewer }) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'anomalous-gallery-card';
|
||||
|
||||
const isVideo = isGalleryVideo(imgData);
|
||||
card.classList.toggle('is-video', isVideo);
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = `/anomalous/output_thumbnail?${query}`; // drags and the viewer use the original
|
||||
img.loading = 'lazy';
|
||||
img.title = t('materialViewOriginal');
|
||||
img.title = isVideo ? t('galleryViewVideo') : t('materialViewOriginal');
|
||||
// The card is dragged, not the <img>: Chrome adds a dragged <img> as a file, and ComfyUI
|
||||
// would load that thumbnail (no workflow in it) instead of fetching the original's link.
|
||||
img.draggable = false;
|
||||
@@ -143,18 +152,26 @@ export function createGalleryCard(owner, imgData, { showViewer }) {
|
||||
setTimeout(() => owner.modal?.classList.add('anomalous-material-dragging'), 0);
|
||||
});
|
||||
card.addEventListener('dragend', () => owner.modal?.classList.remove('anomalous-material-dragging'));
|
||||
img.onclick = () => (owner.gallerySelectModel ? useAsCover(owner, image) : showViewer(url));
|
||||
img.onclick = () => (owner.gallerySelectModel ? useAsCover(owner, image) : showViewer(url, isVideo));
|
||||
const remove = button('anomalous-gallery-delete', t('galleryDelete'), () => deleteImage(owner, image, card));
|
||||
remove.innerHTML = DELETE_ICON; // static markup only
|
||||
|
||||
if (isVideo) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'anomalous-gallery-video-badge';
|
||||
badge.innerHTML = PLAY_ICON; // static markup only
|
||||
badge.append(image.filename.split('.').pop().toUpperCase());
|
||||
card.append(img, remove, badge);
|
||||
return card;
|
||||
}
|
||||
card.addEventListener('contextmenu', (event) => {
|
||||
if (!owner.gallerySelectModel) openGalleryCardMenu(event, image, { owner, openDetails: () => openWorkbench(owner, image, url) });
|
||||
});
|
||||
|
||||
const details = button('anomalous-gallery-details', t('materialViewDetails'), () => {
|
||||
if (!owner.gallerySelectModel) openWorkbench(owner, image, url);
|
||||
});
|
||||
details.innerHTML = DETAILS_ICON; // static markup only
|
||||
details.append(Object.assign(document.createElement('span'), { textContent: t('materialViewParameters') }));
|
||||
const remove = button('anomalous-gallery-delete', t('galleryDelete'), () => deleteImage(owner, image, card));
|
||||
remove.innerHTML = DELETE_ICON; // static markup only
|
||||
|
||||
card.append(img, details, remove);
|
||||
// Only PNG outputs carry the workflow that can be kept.
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* The output gallery's filters (state in gallery_filter.js): All / Pictures / Videos beside its
|
||||
* search, shown once the output folder has a video (or while Pictures or Videos is chosen), and
|
||||
* Settings → Gallery's formats, each clicked to hide it or show it again.
|
||||
*/
|
||||
|
||||
import { translate as t } from './locales.js';
|
||||
import {
|
||||
GALLERY_IMAGE_FORMATS,
|
||||
GALLERY_VIDEO_FORMATS,
|
||||
galleryMedia,
|
||||
hiddenGalleryFormats,
|
||||
setGalleryFormatHidden,
|
||||
setGalleryMedia,
|
||||
} from './gallery_filter.js';
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** The switch; `onChange()` after a click, `switch.refresh(hasVideos)` after each listing. */
|
||||
export function createGalleryMediaSwitch(onChange) {
|
||||
const box = el('div', 'anomalous-gallery-media');
|
||||
box.setAttribute('role', 'radiogroup');
|
||||
box.hidden = galleryMedia() === 'all';
|
||||
const choices = [['all', 'galleryFilterAll'], ['image', 'galleryFilterImages'], ['video', 'galleryFilterVideos']]
|
||||
.map(([media, labelKey]) => {
|
||||
const choice = el('button', 'anomalous-gallery-media-btn', t(labelKey));
|
||||
choice.type = 'button';
|
||||
choice.setAttribute('role', 'radio');
|
||||
choice.dataset.media = media;
|
||||
choice.onclick = () => {
|
||||
if (galleryMedia() === media) return;
|
||||
setGalleryMedia(media);
|
||||
mark();
|
||||
onChange();
|
||||
};
|
||||
return choice;
|
||||
});
|
||||
const mark = () => {
|
||||
const current = galleryMedia();
|
||||
for (const choice of choices) choice.setAttribute('aria-checked', String(choice.dataset.media === current));
|
||||
};
|
||||
box.append(...choices);
|
||||
mark();
|
||||
box.refresh = (hasVideos) => {
|
||||
box.hidden = !hasVideos && galleryMedia() === 'all';
|
||||
mark();
|
||||
};
|
||||
return box;
|
||||
}
|
||||
|
||||
/** Settings → Gallery: one line of pictures, one of videos; `onChange()` after a click. */
|
||||
export function createGalleryFormatSettings(onChange) {
|
||||
const box = el('div', 'anomalous-gallery-formats');
|
||||
const hidden = hiddenGalleryFormats();
|
||||
for (const [labelKey, formats] of [['galleryFilterImages', GALLERY_IMAGE_FORMATS], ['galleryFilterVideos', GALLERY_VIDEO_FORMATS]]) {
|
||||
const line = el('div', 'anomalous-gallery-formats-line');
|
||||
line.append(el('span', 'anomalous-gallery-formats-label', t(labelKey)));
|
||||
for (const format of formats) {
|
||||
const toggle = el('button', 'anomalous-gallery-format', format.toUpperCase());
|
||||
toggle.type = 'button';
|
||||
const mark = (shown) => {
|
||||
toggle.setAttribute('aria-pressed', String(shown));
|
||||
toggle.title = t(shown ? 'galleryFormatShown' : 'galleryFormatHidden');
|
||||
};
|
||||
mark(!hidden.has(format));
|
||||
toggle.onclick = () => {
|
||||
const shown = toggle.getAttribute('aria-pressed') !== 'true';
|
||||
setGalleryFormatHidden(format, !shown);
|
||||
mark(shown);
|
||||
onChange();
|
||||
};
|
||||
line.append(toggle);
|
||||
}
|
||||
box.append(line);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { saveApiKey } from './ui_scan_settings.js';
|
||||
import { exportBackup, importBackup } from './ui_backup.js';
|
||||
import { followsSelection, setFollowsSelection } from './shell_open_rules.js';
|
||||
import { FOLDER_PRESETS, expandFolder } from './download_places.js';
|
||||
import { createGalleryFormatSettings } from './ui_gallery_filter.js';
|
||||
|
||||
const t = (key, params) => translate(key, params);
|
||||
// The full written guide: the README, at its Chinese half for Chinese.
|
||||
@@ -185,6 +186,13 @@ function cardsGroup(owner, redraw) {
|
||||
);
|
||||
}
|
||||
|
||||
function galleryGroup(owner) {
|
||||
return group('settingsGallery',
|
||||
// The gallery lists again when it is next shown.
|
||||
row('galleryFormatsTitle', 'galleryFormatsHelp', createGalleryFormatSettings(() => { owner.galleryLoaded = false; })),
|
||||
);
|
||||
}
|
||||
|
||||
function foldersGroup(owner) {
|
||||
return group('settingsFolders',
|
||||
row('sidebarManageFolders', 'settingsFoldersHelp',
|
||||
@@ -381,7 +389,7 @@ function helpGroup(owner) {
|
||||
|
||||
// Tab -> its title and its cards, in order.
|
||||
const TABS = [
|
||||
['look', 'settingsTabLook', (owner, redraw) => [appearanceGroup(owner, redraw), cardsGroup(owner, redraw), openingGroup(owner, redraw)]],
|
||||
['look', 'settingsTabLook', (owner, redraw) => [appearanceGroup(owner, redraw), cardsGroup(owner, redraw), galleryGroup(owner), openingGroup(owner, redraw)]],
|
||||
['models', 'settingsTabModels', (owner, redraw) => [foldersGroup(owner), downloadGroup(owner, redraw)]],
|
||||
['workflows', 'settingsTabWorkflows', (owner) => [workflowGroup(owner)]],
|
||||
['prompts', 'settingsTabPrompts', () => [translationGroup()]],
|
||||
|
||||
Reference in New Issue
Block a user