Files
DemonGatanjieuandClaude Fable 5.2 99ee111c81 Give a private task's AI the gist to read, keep its model names as tokens
Privacy mode left the AI blind twice over: the whole prompt was a token, so
it could not help with light, lens or style, and model and LoRA file names
still went out as written.

- The prompt box has a first field, the gist: the picture in words the user
  writes for the AI to read. seal_answer hands it on as written beside the
  tokens of the private prompts; either alone can be handed in.
- ai_sealed_models.py: while the Gates window's "Model names in a private
  task" is on (the default), each model of the library is one token ([M1],
  sealed as its model_id) however an answer writes it: model_id, loader
  name, file name in a text, card name. A model's own entry drops name,
  file, folder, links and hash and keeps type, base model, the author's
  settings and the user's note. expand writes a model token back as the
  model_id, as the loader's name among a node's values, as the card's name
  inside the AI's words. The task's menu lists what each token stands for.
- The model editor has "Note for the AI" (ai_note, note_for_ai in the
  tools, searched by search_models): what a model is for, in words the AI
  may read.
- A combo's name is sealed too: unless renamed it is made of its model's
  name and the start of its prompt.
- The privacy skill says how to write around a gist and how to pick and
  pass on model tokens.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 18:36:20 +08:00

373 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The model editor (the details' Edit, a notes card's pencil, the "read from the file" note):
* the cover on the left (the one shown, Civitai's own when the one shown is the user's, recent
* pictures, a file to upload, or the whole gallery), the name, the source link and the notes on
* the right, Reset, Cancel and Save always in view. Nothing is written before Save; then the
* cover goes first and the fields after (renaming the file takes its cover along). Fields left as
* they were are not sent, so they do not count as the user's own. Esc or a click outside closes.
*/
import { translate } from './locales.js';
import { isPhysicalRenameProtectedType } from './model_policies.js';
import { detectPlatform, normalizeUrl } from './model_source_links.js';
import { usableSourceUrl } from './model_source_data.js';
import { reasonText } from './scan_results.js';
const t = (key, params) => translate(key, params);
const RECENT_PICTURES = 6;
const VIDEO = /\.(mp4|webm|mov|avi)(?:[?&#]|$)/i;
const PICTURE = /\.(png|jpe?g|webp|gif|avif)$/i;
let fieldCount = 0;
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function button(className, label, onClick) {
const node = el('button', className, label);
node.type = 'button';
node.onclick = onClick;
return node;
}
/** A field: its label (for `input`, the control itself unless it is a row holding one), the control, the lines under it. */
function field(parent, label, control, under = [], input = control) {
const box = el('div', 'anomalous-medit-field');
const name = el('label', 'anomalous-medit-label', label);
input.id = `anomalous-medit-field-${++fieldCount}`;
name.htmlFor = input.id;
box.append(name, control, ...under);
parent.append(box);
return box;
}
/** The model's own place, as the cover and metadata routes take it. */
function placeOf(owner, model) {
const sub = String(model.subfolder ?? '').replace(/^\/+/, '');
return {
type: model.type || owner.currentType,
path_idx: model.path_idx ?? owner.currentPathIdx ?? 0,
subfolder: model.subfolder == null ? owner.currentSubfolder || '/' : sub ? `/${sub}` : '/',
filename: model.filename,
};
}
async function postJson(url, body) {
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const data = await res.json().catch(() => ({}));
if (!res.ok || data.status !== 'success') throw new Error(data.message || `HTTP ${res.status}`);
return data;
}
/** The line under the title: where the model's information came from ('' for files scans never read). */
function originLine(model, meta) {
if (meta.info_source === 'civitai') return t('modelEditFromCivitai');
if (meta.info_source === 'local') {
return meta.unmatched_reason === 'damaged' ? `⚠ ${t('modelSourceDamaged')}`
: `≈ ${t('modelSourceLocal')} · ${reasonText(meta.unmatched_reason)}`;
}
return /\.safetensors$/i.test(model.filename) ? t('modelSourceNotScanned') : '';
}
/** A web address to keep ('' for none), or null when it is not one. */
function linkValue(raw) {
const text = raw.trim();
if (!text) return '';
const url = normalizeUrl(text);
return /^https?:\/\/[^\s/]+\.[^\s]+$/i.test(url) && usableSourceUrl(url) ? url : null;
}
/**
* The cover side: the chosen cover large, the choices under it. `pick(change)` gets what Save is
* to do: null keeps the cover, { picture } copies an output picture, { file } uploads one,
* { civitai: true } brings Civitai's own back. Returns { dispose } (object URLs to give back).
*/
function coverSide(parent, model, place, pick, toGallery) {
const side = el('div', 'anomalous-medit-cover-side');
const preview = el('div', 'anomalous-medit-cover');
const hint = el('small', 'anomalous-medit-hint');
const choices = el('div', 'anomalous-medit-choices');
const objectUrls = [];
side.append(preview, el('span', 'anomalous-medit-label', t('modelEditCover')), choices, hint);
parent.append(side);
const show = (url, video = VIDEO.test(url || '')) => {
preview.replaceChildren();
if (!url) {
preview.append(el('span', 'anomalous-medit-cover-empty', t('modelEditNoCover')));
return;
}
const media = el(video ? 'video' : 'img');
if (video) Object.assign(media, { muted: true, autoplay: true, loop: true, playsInline: true });
else media.alt = '';
media.src = url;
preview.append(media);
};
const choose = (tile, url, change, note, video) => {
choices.querySelectorAll('.is-selected').forEach(other => other.classList.remove('is-selected'));
tile.classList.add('is-selected');
show(url, video);
hint.textContent = note;
pick(change);
};
const tile = (title, thumb, onClick, className = '') => {
const node = button(`anomalous-medit-choice${className ? ` ${className}` : ''}`, undefined, onClick);
node.title = title;
node.setAttribute('aria-label', title);
if (thumb) {
const img = el('img');
img.alt = '';
img.loading = 'lazy';
img.src = thumb;
img.onerror = () => img.remove();
node.append(img);
}
return node;
};
const thumbOf = url => `${url}&variant=${VIDEO.test(url) ? 'poster' : 'card'}`;
const current = model.preview_url || '';
const now = tile(t('modelEditCoverNow'), current ? thumbOf(current) : '', () => choose(now, current, null, ''), 'is-selected');
if (!current) now.append(el('span', 'anomalous-medit-choice-mark', '∅'));
const upload = tile(t('modelEditCoverUpload'), '', () => input.click(), 'is-action');
upload.append(el('span', 'anomalous-medit-choice-mark', '⤒'));
const input = el('input');
input.type = 'file';
input.accept = 'image/*,video/mp4,video/webm';
input.hidden = true;
input.onchange = () => {
const file = input.files?.[0];
if (!file) return;
const url = URL.createObjectURL(file);
const video = file.type.startsWith('video/');
objectUrls.push(url);
const chosen = tile(file.name, video ? '' : url, () => choose(chosen, url, { file }, t('modelEditCoverHint'), video));
if (video) chosen.append(el('span', 'anomalous-medit-choice-mark', '▶'));
upload.before(chosen);
chosen.click();
input.value = '';
};
const gallery = tile(t('modelEditCoverGallery'), '', toGallery, 'is-action');
gallery.append(el('span', 'anomalous-medit-choice-mark', '…'));
choices.append(now, upload, gallery, input);
show(current);
// Civitai's own cover, when the one shown is the user's; then the newest pictures.
const query = new URLSearchParams(place);
fetch(`/anomalous/model_covers?${query}`).then(res => (res.ok ? res.json() : null)).then((covers) => {
if (!covers?.custom || !covers.civitai || !side.isConnected) return;
const civitai = tile(t('modelEditCoverCivitai'), thumbOf(covers.civitai), () => choose(civitai, covers.civitai, { civitai: true }, t('modelEditCoverCivitaiHint')));
now.after(civitai);
}).catch(() => {});
fetch(`/anomalous/gallery_images?page=1&limit=${RECENT_PICTURES * 2}`).then(res => (res.ok ? res.json() : null)).then((data) => {
if (!side.isConnected) return;
const pictures = (data?.images || []).filter(image => PICTURE.test(image.filename || '')).slice(0, RECENT_PICTURES);
for (const image of pictures) {
const thumb = `/anomalous/output_thumbnail?${new URLSearchParams({ filename: image.filename, subfolder: image.subfolder || '' })}`;
const source = image.subfolder ? `${image.subfolder}/${image.filename}` : image.filename;
const chosen = tile(image.filename, thumb, () => choose(chosen, thumb, { picture: source }, t('modelEditCoverHint'), false));
upload.before(chosen);
}
}).catch(() => {});
return { dispose: () => objectUrls.forEach(url => URL.revokeObjectURL(url)) };
}
/** Puts the cover change in (before the fields, so a renamed file takes it along). */
async function applyCover(place, change) {
if (change?.picture) {
await postJson('/anomalous/set_custom_cover', { ...place, source_image: change.picture });
} else if (change?.file) {
const form = new FormData();
for (const [key, value] of Object.entries(place)) form.append(key, value);
form.append('image', change.file);
const res = await fetch('/anomalous/upload_custom_cover', { method: 'POST', body: form });
const data = await res.json().catch(() => ({}));
if (!res.ok || data.status !== 'success') throw new Error(data.message || `HTTP ${res.status}`);
}
}
/** After a save: the grid again, and the details of this model when they are open. */
async function refresh(owner, model, place, filename) {
owner.loadModels?.();
const open = owner.currentDetailModel;
if (!open || (open.filename !== model.filename && open.filename !== filename)) return;
const rel = [place.subfolder.replace(/^\/+|\/+$/g, ''), filename].filter(Boolean).join('/');
const params = new URLSearchParams({ type: place.type, path_idx: place.path_idx, rel });
const data = await fetch(`/anomalous/scan_model?${params}`).then(res => res.json()).catch(() => ({}));
if (data.model) {
owner.currentDetailModel = data.model;
owner.showDetail(data.model);
}
}
export function showEditModal(model) {
const owner = this;
const meta = model.metadata || {};
const place = placeOf(owner, model);
let coverChange = null;
let busy = false;
const overlay = el('div', 'anomalous-medit-overlay');
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
const dialog = el('div', 'anomalous-medit');
overlay.append(dialog);
const head = el('div', 'anomalous-medit-head');
const titles = el('div', 'anomalous-medit-titles');
titles.append(el('h3', '', t('modelEditTitle')), el('p', 'anomalous-medit-file', model.filename));
const origin = originLine(model, meta);
if (origin) titles.append(el('p', `anomalous-medit-origin is-${meta.info_source || 'none'}`, origin));
const shut = button('anomalous-medit-close', '×', () => close());
shut.setAttribute('aria-label', t('modelEditClose'));
head.append(titles, shut);
const body = el('div', 'anomalous-medit-body');
const dirty = () => coverChange || nameInput.value.trim() !== (meta.custom_name || '')
|| notesInput.value.trim() !== (meta.custom_notes || '').trim() || linkInput.value.trim() !== (meta.source_url || '')
|| aiNoteInput.value.trim() !== (meta.ai_note || '');
const cover = coverSide(body, model, place, (change) => { coverChange = change; }, () => {
if (dirty() && !confirm(t('modelEditLeaveForGallery'))) return;
close();
owner.showGallerySelectMode(model);
});
const fields = el('div', 'anomalous-medit-fields');
body.append(fields);
const nameInput = el('input', 'anomalous-medit-input');
nameInput.type = 'text';
nameInput.maxLength = 200;
nameInput.value = meta.custom_name || '';
nameInput.placeholder = meta.name || model.filename;
const renameRow = el('label', 'anomalous-medit-check');
const rename = el('input');
rename.type = 'checkbox';
const locked = isPhysicalRenameProtectedType(place.type);
rename.disabled = locked;
renameRow.classList.toggle('is-disabled', locked);
renameRow.append(rename, el('span', '', t('modelEditRename')));
const renameHint = el('small', 'anomalous-medit-hint', t(locked ? 'modelEditRenameProtected' : 'modelEditNameHint'));
rename.onchange = () => { renameHint.textContent = t(rename.checked ? 'modelEditRenameHint' : 'modelEditNameHint'); };
field(fields, t('modelEditName'), nameInput, [renameRow, renameHint]);
const linkRow = el('div', 'anomalous-medit-link');
const linkInput = el('input', 'anomalous-medit-input');
linkInput.type = 'text';
linkInput.inputMode = 'url';
linkInput.spellcheck = false;
linkInput.maxLength = 2048;
linkInput.value = meta.source_url || '';
linkInput.placeholder = t('modelEditLinkPlaceholder');
const site = el('span', 'anomalous-medit-site');
const showSite = () => {
const platform = linkInput.value.trim() ? detectPlatform(linkInput.value) : null;
site.hidden = !platform;
site.textContent = platform?.name === 'Web Link' ? t('modelEditLinkWeb') : platform?.name || '';
site.style.color = platform?.color || '';
site.style.borderColor = platform?.border || '';
};
linkInput.oninput = showSite;
showSite();
linkRow.append(linkInput, site);
const linkHint = meta.info_source === 'local' ? 'modelEditLinkLocalHint' : meta.civitai_url ? 'modelEditLinkCivitaiHint' : 'modelEditLinkHint';
field(fields, t('modelEditLink'), linkRow, [el('small', 'anomalous-medit-hint', t(linkHint))], linkInput);
const notesInput = el('textarea', 'anomalous-medit-input anomalous-medit-notes');
notesInput.maxLength = 5000;
notesInput.rows = 6;
notesInput.value = meta.custom_notes || '';
notesInput.placeholder = t('modelEditNotesPlaceholder');
field(fields, t('modelEditNotes'), notesInput);
const aiNoteInput = el('input', 'anomalous-medit-input');
aiNoteInput.type = 'text';
aiNoteInput.maxLength = 300;
aiNoteInput.value = meta.ai_note || '';
aiNoteInput.placeholder = t('modelEditAiNotePlaceholder');
field(fields, t('modelEditAiNote'), aiNoteInput, [el('small', 'anomalous-medit-hint', t('modelEditAiNoteHint'))]);
const foot = el('div', 'anomalous-medit-foot');
const reset = button('anomalous-medit-reset', t('modelEditReset'), () => resetAll());
reset.title = t('modelEditResetHint');
const error = el('div', 'anomalous-medit-error');
error.setAttribute('role', 'alert');
const cancel = button('anomalous-medit-cancel', t('modelEditCancel'), () => close());
const save = button('anomalous-btn-primary anomalous-medit-save', t('modelEditSave'), () => submit());
foot.append(reset, error, cancel, save);
dialog.append(head, body, foot);
function close() {
cover.dispose();
overlay.remove();
}
function setBusy(on) {
busy = on;
for (const node of [reset, cancel, save]) node.disabled = on;
save.textContent = t(on ? 'modelEditSaving' : 'modelEditSave');
}
function fail(message, focus) {
error.textContent = message;
focus?.focus();
}
async function submit() {
if (busy) return;
const name = nameInput.value.trim();
const notes = notesInput.value.trim();
const link = linkValue(linkInput.value);
if (link === null) return fail(t('modelEditLinkInvalid'), linkInput);
if (rename.checked && !name) return fail(t('modelEditRenameNeedsName'), nameInput);
const request = { ...place, physical_rename: rename.checked };
if (name !== (meta.custom_name || '') || rename.checked) request.custom_name = name;
if (notes !== (meta.custom_notes || '').trim()) request.custom_notes = notes;
if (aiNoteInput.value.trim() !== (meta.ai_note || '')) request.ai_note = aiNoteInput.value.trim();
if (link !== (meta.source_url || '')) request.custom_source_url = link;
if (coverChange?.civitai) request.reset_cover = true;
setBusy(true);
error.textContent = '';
let filename = model.filename;
try {
await applyCover(place, coverChange);
const data = await postJson('/anomalous/update_metadata', request);
filename = data.new_filename || filename;
} catch (failure) {
setBusy(false);
return fail(t('modelEditSaveFailed', { error: failure.message }));
}
close();
await refresh(owner, model, place, filename);
}
async function resetAll() {
if (busy || !confirm(t('modelEditResetConfirm'))) return;
setBusy(true);
try {
const data = await postJson('/anomalous/update_metadata', { ...place, custom_name: '', custom_notes: '', ai_note: '', reset_cover: true, physical_rename: false });
close();
if (data.cover_reset === false) alert(t(data.cover_reset_source === 'preserved_current' ? 'modelEditResetCoverKept' : 'modelEditResetCoverFailed'));
await refresh(owner, model, place, model.filename);
} catch (failure) {
setBusy(false);
fail(t('modelEditSaveFailed', { error: failure.message }));
}
}
overlay.addEventListener('mousedown', (event) => {
if (event.target === overlay && !busy) close();
});
overlay.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
event.stopPropagation();
if (!busy) close();
} else if (event.key === 'Enter' && (event.target.tagName === 'INPUT' && event.target.type === 'text' || event.ctrlKey || event.metaKey)) {
event.preventDefault();
submit();
}
});
document.body.append(overlay);
nameInput.focus();
}