feat(inspector): share complete file details and exports
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
import { createScope, createButton } from '../shared/toolkit/core.js';
|
||||
import { mountPromptBlocks, promptRoles } from './prompt-blocks.mjs';
|
||||
import { createInspectionController } from './inspection-state.mjs';
|
||||
import { createSendButton } from './prompt-sender.mjs';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
|
||||
const RAW_KEYS = ['prompt', 'workflow', 'parameters', 'comfyassets_prompt_pairs'];
|
||||
const RAW_LIMIT = 'Raw metadata exceeds the response limit. Download the original file to inspect it externally.';
|
||||
const CONTAINER_WORKFLOW = 'Original workflow JSON text is unavailable for this container record. Download metadata or the original file.';
|
||||
const REPRESENTATIONS = {
|
||||
'original-text': 'Original embedded text',
|
||||
'reserialized-json': 'Reserialized container JSON; numeric spelling/precision may differ',
|
||||
invalid: 'Invalid container record; text is unavailable',
|
||||
};
|
||||
const FACTS = [['seed', 'Seed'], ['steps', 'Steps'], ['cfg', 'CFG'], ['sampler', 'Sampler'], ['scheduler', 'Scheduler'], ['model', 'Model']];
|
||||
const FACT_SOURCES = {'api-graph': 'Saved API graph', workflow: 'Saved workflow', 'embedded-parameters': 'Embedded generation parameters'};
|
||||
|
||||
function safeFilename(value) {
|
||||
return String(value || 'file').replace(/[\\/\x00-\x1f\x7f<>:"|?*]/g, '_').replace(/^\.+|[. ]+$/g, '') || 'file';
|
||||
}
|
||||
|
||||
/** Export only the bounded public evidence, never normalized graphs or container carriers. */
|
||||
export function inspectionDownloads(record) {
|
||||
const name = safeFilename(record?.download?.filename || record?.identity?.path?.split('/').at(-1));
|
||||
const stem = name.replace(/\.[^.]+$/, '') || 'file';
|
||||
const unavailable = (suffix, reason) => ({available:false, reason, filename:`${stem}.${suffix}.json`, bodyText:null, mime:'application/json'});
|
||||
const metadata = record?.metadata;
|
||||
const reason = metadata?.status === 'limited' ? RAW_LIMIT : 'Full metadata is unavailable for this inspection response.';
|
||||
const result = {metadata:unavailable('metadata', reason), workflow:unavailable('workflow', reason)};
|
||||
if (!metadata || metadata.status === 'limited') return result;
|
||||
const raw = {}, record_formats = {}, representations = {};
|
||||
for (const key of RAW_KEYS) {
|
||||
if (Object.hasOwn(REPRESENTATIONS, metadata.record_formats?.[key])) {
|
||||
record_formats[key] = metadata.record_formats[key];
|
||||
representations[key] = REPRESENTATIONS[record_formats[key]];
|
||||
if (record_formats[key] !== 'invalid' && typeof metadata.raw?.[key] === 'string') raw[key] = metadata.raw[key];
|
||||
}
|
||||
}
|
||||
const envelope = {inspection_version:1, identity:record.identity, file:record.file, prompts:record.prompts,
|
||||
generation:record.generation, library:record.library,
|
||||
metadata:{status:metadata.status, omitted_reason:metadata.omitted_reason, raw, record_formats, representations,
|
||||
scope:'Supported embedded records only; one selected container carrier for video. Other metadata and private carrier text are excluded.'}};
|
||||
result.metadata = {...result.metadata, available:true, reason:null, bodyText:JSON.stringify(envelope, null, 2)};
|
||||
if (record_formats.workflow === 'reserialized-json') result.workflow.reason = CONTAINER_WORKFLOW;
|
||||
else if (record_formats.workflow === 'original-text' && metadata.workflow_graph?.status === 'parsed' && typeof raw.workflow === 'string') {
|
||||
result.workflow = {...result.workflow, available:true, reason:null, bodyText:raw.workflow};
|
||||
} else result.workflow.reason = 'A valid original workflow JSON record is not available in this file.';
|
||||
return result;
|
||||
}
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
function childScope(parent) {
|
||||
const scope = createScope(), dispose = () => scope.dispose();
|
||||
parent.signal.addEventListener('abort', dispose, {once:true});
|
||||
scope.cleanup(() => parent.signal.removeEventListener('abort', dispose));
|
||||
if (parent.signal.aborted) scope.dispose();
|
||||
return scope;
|
||||
}
|
||||
function disclosure(label) {
|
||||
const node = el('details'); node.append(el('summary', label)); return node;
|
||||
}
|
||||
function definitionList(entries, className) {
|
||||
const list = el('dl', undefined, className);
|
||||
for (const [label, value] of entries) {
|
||||
const row = el('div'); row.append(el('dt', label), el('dd', value ?? 'Not available')); list.append(row);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
function factText(fact) {
|
||||
if (fact?.status === 'known' && typeof fact.value === 'string') return fact.value;
|
||||
if (fact?.status === 'ambiguous') return 'Ambiguous';
|
||||
if (fact?.status === 'absent') return 'Not recorded';
|
||||
return fact?.reason === 'projection-limited' ? 'Unavailable within inspection limits' : 'Unresolved';
|
||||
}
|
||||
function formatBytes(value) {
|
||||
return value === null || value === undefined ? null : value >= 1048576 ? `${(value / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(value / 1024))} KB`;
|
||||
}
|
||||
|
||||
export function mountFileInspector(container, {sender, parentScope, inline = false, onPreview, onRetry, onRefresh, onInteraction = () => {}}) {
|
||||
if (parentScope.signal.aborted) return {setState() {}, dispose() {}};
|
||||
const scope = childScope(parentScope);
|
||||
let contentScope;
|
||||
container.classList.add('file-inspector');
|
||||
scope.listen(container, 'pointerdown', onInteraction);
|
||||
scope.listen(container, 'keydown', onInteraction);
|
||||
scope.cleanup(() => {contentScope?.dispose(); container.replaceChildren(); container.classList.remove('file-inspector');});
|
||||
|
||||
function setState(state) {
|
||||
if (scope.signal.aborted) return;
|
||||
contentScope?.dispose(); contentScope = childScope(scope);
|
||||
container.replaceChildren();
|
||||
if (state.loading || state.error) {
|
||||
const status = el('p', state.loading ? 'Loading file details…' : state.refreshRequired ? state.message : 'Could not read prompts from this file.', 'file-status muted');
|
||||
status.setAttribute('role', state.error ? 'alert' : 'status'); container.append(status);
|
||||
if (state.error && state.refreshRequired) {
|
||||
if (onRefresh) {
|
||||
const refresh = createButton('Refresh image collection');
|
||||
contentScope.listen(refresh, 'click', onRefresh); container.append(refresh);
|
||||
} else container.append(el('p', 'Reopen this file from a refreshed image collection.', 'muted'));
|
||||
} else if (state.error && onRetry) {
|
||||
const retry = createButton('Retry prompt details'); contentScope.listen(retry, 'click', onRetry); container.append(retry);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const record = state.record;
|
||||
if (!record) return;
|
||||
const {file, library, metadata} = record;
|
||||
const filename = record.identity.path.split('/').at(-1);
|
||||
if (inline && record.download) {
|
||||
const cover = createButton('', 'file-cover'); cover.setAttribute('aria-label', 'Open full preview');
|
||||
const video = file?.media_type === 'video';
|
||||
const media = el(video ? 'video' : 'img'); media.src = record.download.url;
|
||||
if (video) {
|
||||
media.muted = true; media.playsInline = true; media.preload = 'metadata';
|
||||
contentScope.cleanup(() => {media.pause(); media.removeAttribute('src'); media.load();});
|
||||
} else {media.alt = filename; media.decoding = 'async'; contentScope.cleanup(() => media.removeAttribute('src'));}
|
||||
cover.append(media); if (onPreview) contentScope.listen(cover, 'click', onPreview);
|
||||
else {cover.setAttribute('aria-disabled', 'true'); cover.title = 'Full preview is unavailable in this view.';}
|
||||
container.append(cover);
|
||||
}
|
||||
const inner = el('div', undefined, 'file-inspector-inner'); container.append(inner);
|
||||
const header = el('div', undefined, 'file-title');
|
||||
header.append(el('h2', library?.status === 'linked' && library.title ? library.title : filename));
|
||||
const extension = file?.extension || filename.split('.').at(-1);
|
||||
header.append(el('span', file?.format || `${extension.toUpperCase()} extension`, 'file-format file-chip'));
|
||||
inner.append(header, el('p', filename, 'file-name muted'));
|
||||
const chips = el('div', undefined, 'file-chips');
|
||||
const sources = new Set(Object.values(record.prompts).filter(role => role?.status === 'resolved').map(role => role.source));
|
||||
const evidence = sources.has('execution') ? 'Execution prompt evidence' : sources.has('graph-inferred') ? 'Workflow prompt evidence' : sources.has('embedded-parameters') ? 'Embedded parameter evidence' : null;
|
||||
if (evidence) chips.append(el('span', evidence, 'file-chip file-evidence'));
|
||||
if (library?.status === 'linked' && library.category) {
|
||||
const label = library.metadata_scope === 'pair' ? 'Recipe category' : 'Positive prompt category';
|
||||
chips.append(el('span', `${label}: ${library.category}`, 'file-chip'));
|
||||
}
|
||||
if (chips.childNodes.length) inner.append(chips);
|
||||
const prompts = el('div'); inner.append(prompts);
|
||||
const blocks = mountPromptBlocks(prompts, {sender, parentScope:contentScope, includePairAction:false});
|
||||
blocks.setState({prompts:record.prompts}); contentScope.cleanup(blocks.dispose);
|
||||
if (library?.status === 'linked') {
|
||||
const vocabulary = el('div', undefined, 'file-vocabulary');
|
||||
vocabulary.append(el('p', library.metadata_scope === 'pair' ? 'Recipe tags' : 'Positive prompt tags', 'muted'));
|
||||
const tags = el('div', undefined, 'file-chips');
|
||||
for (const tag of library.tags) tags.append(el('span', tag, 'file-chip'));
|
||||
vocabulary.append(library.tags.length ? tags : el('p', 'No saved tags', 'muted')); inner.append(vocabulary);
|
||||
} else if (!library) inner.append(el('p', 'Saved vocabulary is unavailable for this inspection response.', 'muted'));
|
||||
const facts = definitionList(FACTS.map(([key, label]) => [label, factText(record.generation[key])]), 'generation-facts');
|
||||
inner.append(facts);
|
||||
const provenance = disclosure('File & provenance');
|
||||
provenance.append(el('p', 'Current file at this source; historical bytes are not verified.', 'muted'));
|
||||
if (file) provenance.append(definitionList([
|
||||
['Source', file.source_label], ['Path', record.identity.path], ['Size', formatBytes(file.size)],
|
||||
['Dimensions', file.width && file.height ? `${file.width} × ${file.height}` : null],
|
||||
['Format', file.format || `${file.extension.toUpperCase()} extension`], ['Media type', file.media_type],
|
||||
['Modified', file.modified_time !== null ? new Date(file.modified_time * 1000).toLocaleString() : null],
|
||||
], 'file-facts'));
|
||||
else provenance.append(el('p', 'Current file observations are unavailable for this inspection response.', 'muted'));
|
||||
for (const [key, label] of FACTS) {
|
||||
const fact = record.generation[key];
|
||||
if (fact?.evidence) provenance.append(el('p', `${label}: ${fact.evidence === 'configuration' ? 'Configuration evidence' : 'Embedded parameter evidence'} · ${FACT_SOURCES[fact.source] || 'Source not recorded'}`, 'muted'));
|
||||
}
|
||||
provenance.append(el('p', record.historyTarget ? 'Saved association; not verified against current file' : library?.status === 'ambiguous' ? 'Saved association is ambiguous; no unique saved positive prompt is available.' : 'No unique saved positive prompt association is available.', 'muted'));
|
||||
if (record.download) {
|
||||
const original = el('a', 'Download original', 'workspace-link'); original.href = record.download.url;
|
||||
original.download = safeFilename(record.download.filename); provenance.append(original);
|
||||
}
|
||||
inner.append(provenance);
|
||||
const raw = disclosure('Workflow & raw metadata');
|
||||
raw.append(el('p', 'Supported embedded records only, not all EXIF or container metadata. Video records come from one selected container carrier; alternate carriers are not merged.', 'muted'));
|
||||
if (!metadata) raw.append(el('p', 'Full metadata is unavailable for this inspection response.', 'muted'));
|
||||
else {
|
||||
if (metadata.status !== 'available') raw.append(el('p', {limited:RAW_LIMIT, absent:'No supported embedded metadata was recorded in this file.', unreadable:'Embedded metadata could not be read.'}[metadata.status], 'muted'));
|
||||
for (const key of RAW_KEYS) {
|
||||
const format = metadata.record_formats[key]; if (!format) continue;
|
||||
raw.append(el('p', key, 'raw-record-name'), el('p', REPRESENTATIONS[format], 'muted'));
|
||||
if (Object.hasOwn(metadata.raw, key)) {
|
||||
const text = el('pre'); text.tabIndex = 0; text.append(el('code', metadata.raw[key])); raw.append(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
const downloads = inspectionDownloads(record);
|
||||
for (const [key, label] of [['metadata', 'Download metadata JSON'], ['workflow', 'Download workflow JSON']]) {
|
||||
const item = downloads[key], action = createButton(label);
|
||||
const group = el('div', undefined, 'file-download'); group.append(action);
|
||||
if (!item.available) {
|
||||
action.setAttribute('aria-disabled', 'true');
|
||||
const explanation = el('p', item.reason, 'muted');
|
||||
// IDs need only be unique within the current document, including its two inspectors.
|
||||
explanation.id = `file-download-${++downloadReasonId}`; action.setAttribute('aria-describedby', explanation.id); group.append(explanation);
|
||||
}
|
||||
contentScope.listen(action, 'click', () => {
|
||||
if (!item.available || contentScope.signal.aborted) return;
|
||||
const url = URL.createObjectURL(new Blob([item.bodyText], {type:item.mime}));
|
||||
let timer, released = false;
|
||||
const release = () => {if (released) return; released = true; clearTimeout(timer); URL.revokeObjectURL(url);};
|
||||
contentScope.cleanup(release);
|
||||
const anchor = el('a'); anchor.href = url; anchor.download = item.filename;
|
||||
container.append(anchor); anchor.click(); anchor.remove(); timer = setTimeout(release, 1000);
|
||||
});
|
||||
raw.append(group);
|
||||
}
|
||||
inner.append(raw);
|
||||
const footer = el('footer', undefined, 'file-inspector-footer'), roles = promptRoles(record.prompts);
|
||||
if (Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {
|
||||
const pair = createSendButton('Send prompt pair'); pair.dataset.sendRole = 'pair';
|
||||
contentScope.listen(pair, 'click', () => sender.open(roles, pair)); footer.append(pair);
|
||||
}
|
||||
if (record.historyTarget) {
|
||||
const history = el('a', 'Prompt history', 'workspace-link'); history.href = record.historyTarget;
|
||||
history.prepend(createWorkspaceIcon('branch')); footer.append(history);
|
||||
}
|
||||
if (footer.childNodes.length) container.append(footer);
|
||||
}
|
||||
return {setState, dispose:() => scope.dispose()};
|
||||
}
|
||||
let downloadReasonId = 0;
|
||||
|
||||
/** Compatible standalone controller; Images owns a single shared controller instead. */
|
||||
export function mountPromptInspector(container, {api, image, sender, parentScope}) {
|
||||
if (parentScope.signal.aborted) return {dispose() {}};
|
||||
const scope = childScope(parentScope), controller = createInspectionController({api});
|
||||
const view = mountFileInspector(container, {sender, parentScope:scope, onRetry:() => controller.retry()});
|
||||
scope.cleanup(controller.subscribe(view.setState)); scope.cleanup(() => controller.dispose()); scope.cleanup(view.dispose);
|
||||
controller.select(image);
|
||||
return {dispose:() => scope.dispose()};
|
||||
}
|
||||
@@ -92,7 +92,7 @@
|
||||
.prompt-copy-status { font-size: 11px; margin-block-end: 0; }
|
||||
.prompt-copy-status:empty { display: none; }
|
||||
.image-preview-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(270px, 340px); }
|
||||
.image-preview-details { padding: 0 18px 18px; max-block-size: 75dvh; overflow: auto; min-inline-size: 0; }
|
||||
.image-preview-details { padding: 0; max-block-size: 75dvh; overflow: auto; min-inline-size: 0; }
|
||||
.image-preview-details .prompt-inspector { margin-block-start: 0; }
|
||||
.image-preview > header { border-block-end: 1px solid var(--ca-line); }
|
||||
.prompt-send-button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; color: var(--ca-focus); }
|
||||
|
||||
+20
-24
@@ -1,7 +1,8 @@
|
||||
import { createScope, createButton, createField, createSelect } from '../shared/toolkit/core.js';
|
||||
import { createImageStream, imageKey } from './images-state.mjs';
|
||||
import { layoutImages, visibleImages, workspaceImageMetrics } from './image-layout.mjs';
|
||||
import { mountPromptInspector } from './prompt-inspector.mjs';
|
||||
import { mountFileInspector } from './file-inspector.mjs';
|
||||
import { createInspectionController } from './inspection-state.mjs';
|
||||
import { createPromptSender } from './prompt-sender.mjs';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
|
||||
@@ -27,6 +28,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
const { resolveUrl } = window.ApiClient;
|
||||
const sender = createPromptSender({scope});
|
||||
let detailInspector, previewInspector;
|
||||
const inspection = createInspectionController({api});
|
||||
scope.cleanup(inspection.subscribe(next => {detailInspector?.setState(next); previewInspector?.setState(next);}));
|
||||
scope.cleanup(() => inspection.dispose());
|
||||
scope.cleanup(() => { detailInspector?.dispose(); previewInspector?.dispose(); });
|
||||
const visit = embedded ? null : lastVisit;
|
||||
let density = visit?.density || 'comfortable';
|
||||
@@ -167,39 +171,23 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
detailInspector?.dispose();
|
||||
selection = imageKey(image);
|
||||
for (const video of details.querySelectorAll('video')) releaseVideo(video);
|
||||
const thumbnail = mediaElement(image);
|
||||
const metadata = el('dl', undefined, 'image-facts');
|
||||
const fields = [['Source', image.root_dir], ['Path', image.path], ['Size', formatBytes(image.size)],
|
||||
['Dimensions', image.width && image.height ? `${image.width} × ${image.height}` : 'Not available'],
|
||||
['Modified', new Date(image.modified_time * 1000).toLocaleString()]];
|
||||
for (const [label, value] of fields) {
|
||||
const row = el('div'); row.append(el('dt', label), el('dd', value)); metadata.append(row);
|
||||
}
|
||||
const preview = createButton('Open full preview', 'ca-primary');
|
||||
// The view delegates these controls so changing selection retains no old handlers.
|
||||
preview.dataset.previewKey = selection;
|
||||
const download = el('a', 'Download original', 'workspace-link');
|
||||
download.href = resolveUrl(image.url); download.download = image.filename;
|
||||
const prompts = el('div');
|
||||
const legacy = el('p', 'Open Current gallery for tagging.', 'muted');
|
||||
legacy.hidden = embedded;
|
||||
details.replaceChildren(thumbnail, el('h2', image.filename), preview, download, prompts, metadata, legacy);
|
||||
detailInspector = mountPromptInspector(prompts, {api, image, sender, parentScope:scope});
|
||||
detailInspector = mountFileInspector(details, {sender, parentScope:scope, inline:true,
|
||||
onPreview:() => previewImage(image), onRetry:() => inspection.retry(), onRefresh:refreshInspectionCollection});
|
||||
detailInspector.setState(inspection.state);
|
||||
inspection.select(image);
|
||||
for (const [key, card] of rendered) {
|
||||
card.classList.toggle('selected', key === selection);
|
||||
card.querySelector('.image-open').setAttribute('aria-pressed', String(key === selection));
|
||||
}
|
||||
}
|
||||
scope.listen(details, 'click', event => {
|
||||
const key = event.target.closest('[data-preview-key]')?.dataset.previewKey;
|
||||
if (key) previewImage(state.items.find(image => imageKey(image) === key));
|
||||
});
|
||||
function previewImage(image) {
|
||||
if (!image) return;
|
||||
if (selection !== imageKey(image)) inspect(image);
|
||||
destroyPreview();
|
||||
previewTitle.textContent = image.filename;
|
||||
previewInspector = mountPromptInspector(previewDetails, {api, image, sender, parentScope:scope});
|
||||
previewInspector = mountFileInspector(previewDetails, {sender, parentScope:scope,
|
||||
onRetry:() => inspection.retry(), onRefresh:refreshInspectionCollection});
|
||||
previewInspector.setState(inspection.state);
|
||||
if (!dialog.open) dialog.showModal();
|
||||
if (image.is_video) {
|
||||
const video = el('video'); video.controls = true; video.src = resolveUrl(image.url); video.preload = 'metadata';
|
||||
@@ -399,12 +387,20 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
observer.observe(continuation);
|
||||
}
|
||||
function reset() {
|
||||
if (dialog.open) dialog.close();
|
||||
destroyPreview();
|
||||
inspection.clear();
|
||||
detailInspector?.dispose(); detailInspector = null;
|
||||
selection = null;
|
||||
for (const video of details.querySelectorAll('video')) releaseVideo(video);
|
||||
details.replaceChildren(el('h2', 'Image details'), el('p', 'Select an image to inspect it.'));
|
||||
stream.reset(filters);
|
||||
}
|
||||
async function refreshInspectionCollection() {
|
||||
if (scope.signal.aborted) return;
|
||||
reset();
|
||||
await loadOptions();
|
||||
}
|
||||
async function loadOptions() {
|
||||
optionsRequest?.abort();
|
||||
const request = new AbortController(); optionsRequest = request;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
.image-details.file-inspector { padding: 0; }
|
||||
.file-inspector { min-inline-size: 0; overflow-wrap: anywhere; }
|
||||
.file-inspector-inner { padding: 17px; }
|
||||
.ca-tools .file-cover { display: block; padding: 0; border: 0; border-radius: 6px 6px 0 0; inline-size: 100%; block-size: 150px; background: var(--ca-field); }
|
||||
.file-cover :where(img, video) { display: block; inline-size: 100%; block-size: 100%; object-fit: cover; border-radius: inherit; }
|
||||
.file-title { display: flex; align-items: start; justify-content: space-between; gap: 8px; }
|
||||
.file-inspector .file-title h2 { font-size: 14px; line-height: 1.5; margin: 0; }
|
||||
.file-name { font-size: 12px; margin-block: 12px; }
|
||||
.file-chips { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.file-chip { display: inline-block; padding: 3px 7px; border: 1px solid var(--ca-line); border-radius: 4px; background: var(--ca-field); font-size: 10px; }
|
||||
.file-evidence { color: var(--ca-focus); }
|
||||
.file-format { flex-shrink: 0; max-inline-size: 50%; }
|
||||
.file-inspector .prompt-inspector { margin-block-start: 15px; }
|
||||
.file-inspector .prompt-block { padding-block: 15px 0; margin-block-start: 15px; }
|
||||
.file-inspector .prompt-text { margin-block: 8px 0; }
|
||||
.file-inspector .prompt-negative { margin-block-end: 12px; }
|
||||
.file-vocabulary > p { margin-block: 0 6px; font-size: 10px; }
|
||||
.file-vocabulary .file-chip { font-size: 12px; }
|
||||
.generation-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-block: 16px; font-size: 11px; }
|
||||
.generation-facts dt, .file-facts dt { color: var(--ca-muted); font-size: 10px; }
|
||||
.generation-facts dd, .file-facts dd { margin: 3px 0 0; }
|
||||
.file-inspector details { border-block-start: 1px solid var(--ca-line); margin-block-start: 16px; padding-block-start: 13px; font-size: 12px; }
|
||||
.file-inspector summary { cursor: pointer; min-block-size: 24px; }
|
||||
.file-inspector :where(summary, button, a):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
|
||||
.file-inspector details p { font-size: 10px; line-height: 1.65; }
|
||||
.file-facts { font-size: 11px; }
|
||||
.file-facts > div { margin-block: 10px; }
|
||||
.file-inspector details pre { white-space: pre-wrap; overflow-wrap: anywhere; max-block-size: 350px; overflow: auto; padding: 10px; background: var(--ca-field); border: 1px solid var(--ca-line); border-radius: 4px; font-size: 11px; line-height: 1.6; }
|
||||
.raw-record-name { font-weight: 600; }
|
||||
.file-download { margin-block: 12px; }
|
||||
.ca-tools .file-download button { font-size: 11px; min-block-size: 30px; }
|
||||
.file-inspector [aria-disabled="true"] { opacity: .6; cursor: not-allowed; }
|
||||
.file-inspector-footer { display: flex; flex-wrap: wrap; gap: 8px; border-block-start: 1px solid var(--ca-line); padding: 13px 17px; }
|
||||
.ca-tools .file-inspector-footer :where(button, a) { font-size: 11px; min-block-size: 30px; }
|
||||
.file-inspector-footer .workspace-link { display: inline-flex; gap: 7px; border: 1px solid var(--ca-line); border-radius: 4px; padding: 5px 8px; }
|
||||
.file-inspector-footer .workspace-icon { inline-size: 14px; block-size: 14px; }
|
||||
.file-status { padding: 17px; }
|
||||
@media (max-width: 700px) { .ca-tools .file-cover { block-size: 200px; } }
|
||||
@@ -34,7 +34,7 @@ function childScope(parentScope) {
|
||||
return scope;
|
||||
}
|
||||
|
||||
export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry}) {
|
||||
export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry, includePairAction = true}) {
|
||||
if (parentScope.signal.aborted) return {update() {}, setState() {}, dispose() {}};
|
||||
const scope = childScope(parentScope);
|
||||
container.classList.add('prompt-inspector');
|
||||
@@ -105,7 +105,7 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
|
||||
} else block.append(el('p', typeof record?.message === 'string' ? record.message : statuses[record?.status] || statuses.unresolved, 'muted'));
|
||||
container.append(block);
|
||||
}
|
||||
if (Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {
|
||||
if (includePairAction && Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {
|
||||
const pair = createSendButton('Send prompt pair'); pair.dataset.sendRole = 'pair';
|
||||
container.append(pair);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,3 @@
|
||||
import { createScope } from '../shared/toolkit/core.js';
|
||||
import { mountPromptBlocks } from './prompt-blocks.mjs';
|
||||
import { createInspectionLoader } from './inspection-state.mjs';
|
||||
|
||||
export { promptRoles, mountPromptBlocks } from './prompt-blocks.mjs';
|
||||
export { inspectPath, createInspectionLoader } from './inspection-state.mjs';
|
||||
|
||||
function childScope(parentScope) {
|
||||
const scope = createScope();
|
||||
const dispose = () => scope.dispose();
|
||||
parentScope.signal.addEventListener('abort', dispose, {once:true});
|
||||
scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose));
|
||||
if (parentScope.signal.aborted) scope.dispose();
|
||||
return scope;
|
||||
}
|
||||
|
||||
export function mountPromptInspector(container, {api, image, sender, parentScope}) {
|
||||
const scope = childScope(parentScope);
|
||||
let loader;
|
||||
const blocks = mountPromptBlocks(container, {sender, parentScope:scope, onRetry:() => loader.load(image)});
|
||||
loader = createInspectionLoader(api, blocks.setState);
|
||||
scope.cleanup(() => loader.dispose());
|
||||
if (!scope.signal.aborted) loader.load(image);
|
||||
return {dispose:() => scope.dispose()};
|
||||
}
|
||||
export { mountPromptInspector } from './file-inspector.mjs';
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<link rel="stylesheet" href="lib/viewerjs/viewer.min.css">
|
||||
<link rel="stylesheet" href="js/workspace/images.css">
|
||||
<link rel="stylesheet" href="js/workspace/library.css">
|
||||
<link rel="stylesheet" href="js/workspace/inspector.css">
|
||||
<script src="lib/viewerjs/viewer.min.js" defer></script>
|
||||
<script src="js/comfy-metadata.js" defer></script>
|
||||
<script type="module" src="js/workspace/index.mjs"></script>
|
||||
|
||||
Reference in New Issue
Block a user