- ui_model_retype.js: on a model's page, and from the toast after importing one model: where it is, what its header suggests (GET /anomalous/placement/look; picked and marked when it can tell, nothing guessed when it cannot), every type with its loader, where it goes, moved as Tidy moves (covers and notes along, never over a file). Kinds and the move's error words are shared (model_import.IMPORT_KINDS, model_placement.moveErrorText). - model_import.move_into: a copy to another drive is compared with the original at its start, middle and end, besides its size, before the original goes. - model_placement: the model a request names is resolved in one place (_source), which also refuses a negative root. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
5.5 KiB
JavaScript
114 lines
5.5 KiB
JavaScript
/**
|
|
* Model import (the models page's Import chip, and model files dropped on the models page):
|
|
* what each file is and where it goes, then putting it there (api/model_import.py does the
|
|
* file work; ui_model_import.js shows it). No DOM.
|
|
*
|
|
* A browser never says where a dropped file is. The server looks for the same file (name,
|
|
* size, time) in this computer's Downloads and Desktop and moves it from there; a file it does
|
|
* not find is uploaded (a copy). Imported files go where the download settings put downloads
|
|
* (`{base}` = the base model's folder), then get scanned like downloads.
|
|
*/
|
|
|
|
import { app } from "../../../scripts/app.js";
|
|
import { expandFolder } from './download_places.js';
|
|
import { startScan, targetsForItems } from './scan_runner.js';
|
|
import { forgetTidyCount } from './model_placement.js';
|
|
|
|
export const MODEL_FILE = /\.(safetensors|sft|gguf|ckpt|pt|pth|bin)$/i;
|
|
/** The model types a file can be put in, in the order they are offered (api/model_import.py KINDS). */
|
|
export const IMPORT_KINDS = ['checkpoints', 'loras', 'vae', 'text_encoders', 'diffusion_models', 'controlnet', 'embeddings',
|
|
'clip_vision', 'upscale_models', 'ipadapter', 'model_patches'];
|
|
const MAX_HEADER = 100 * 1024 * 1024;
|
|
const GGUF_READ = 16 * 1024 * 1024;
|
|
|
|
async function json(url, options) {
|
|
const response = await fetch(url, options);
|
|
const data = await response.json().catch(() => ({}));
|
|
if (!response.ok) {
|
|
const error = new Error(data.error || `HTTP ${response.status}`);
|
|
error.code = data.error || 'http';
|
|
throw error;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
const post = (url, body) => json(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
|
|
/** The model files among dropped or picked files. */
|
|
export const modelFiles = (files) => [...(files || [])].filter(file => MODEL_FILE.test(file.name));
|
|
|
|
/** The file's header bytes: a .safetensors header, the start of a .gguf, nothing for the rest
|
|
* (formats that can carry code are never read). */
|
|
async function readHead(file) {
|
|
if (/\.(safetensors|sft)$/i.test(file.name)) {
|
|
if (file.size < 8) return new Blob();
|
|
const start = new DataView(await file.slice(0, 8).arrayBuffer());
|
|
const size = Number(start.getBigUint64(0, true));
|
|
return size > MAX_HEADER ? new Blob() : file.slice(0, 8 + size);
|
|
}
|
|
if (/\.gguf$/i.test(file.name)) return file.slice(0, GGUF_READ);
|
|
return new Blob();
|
|
}
|
|
|
|
/** { types: { type: roots }, settings }: where models can go, and the download folder rule. */
|
|
export const fetchImportFolders = () => json('/anomalous/import/folders');
|
|
|
|
/** What `file` is: { kind, base, sure, same_name, token?, where? } (token: found on this computer). */
|
|
export async function inspectFile(file) {
|
|
const params = new URLSearchParams({ name: file.name, size: String(file.size), mtime: String(file.lastModified || 0) });
|
|
return json(`/anomalous/import/inspect?${params}`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: await readHead(file),
|
|
});
|
|
}
|
|
|
|
/** A found file's SHA-256, Civitai's record of it and an already scanned copy (slow: reads it all). */
|
|
export const identifyFile = (token) => post('/anomalous/import/identify', { token });
|
|
|
|
/** Where a file goes inside its type folder: the download settings' folder with {base} filled. */
|
|
export function importDestination(name, base, settings, subfolders = []) {
|
|
const folder = expandFolder(settings?.folder ?? '{base}', base, subfolders);
|
|
return folder ? `${folder}/${name}` : name;
|
|
}
|
|
|
|
/** Moves (or with `keep` copies) a found file to `choice` = { type, root, rel }. */
|
|
export const placeFile = (token, choice, keep) => post('/anomalous/import/place', { token, ...choice, keep: Boolean(keep) });
|
|
|
|
/** Uploads `file` to `choice`; `onProgress(sent, total)`. Resolves to where it went; rejects with `.code`. */
|
|
export function uploadFile(file, choice, onProgress) {
|
|
return new Promise((resolve, reject) => {
|
|
const params = new URLSearchParams({ type: choice.type, root: String(choice.root), rel: choice.rel, size: String(file.size) });
|
|
const request = new XMLHttpRequest();
|
|
request.open('PUT', `/anomalous/import/upload?${params}`);
|
|
request.setRequestHeader('Content-Type', 'application/octet-stream');
|
|
request.upload.onprogress = (event) => onProgress?.(event.loaded, event.total || file.size);
|
|
request.onload = () => {
|
|
let data = {};
|
|
try { data = JSON.parse(request.responseText || '{}'); } catch { /* not JSON */ }
|
|
if (request.status >= 200 && request.status < 300) return resolve(data);
|
|
const error = new Error(data.error || `HTTP ${request.status}`);
|
|
error.code = data.error || 'http';
|
|
reject(error);
|
|
};
|
|
request.onerror = () => {
|
|
const error = new Error('network');
|
|
error.code = 'network';
|
|
reject(error);
|
|
};
|
|
request.send(file);
|
|
});
|
|
}
|
|
|
|
/** After a batch was put in place: ComfyUI's model lists, the models page, then one scan of the new files. */
|
|
export async function afterImport(owner, placed) {
|
|
if (!placed.length) return;
|
|
forgetTidyCount();
|
|
try {
|
|
await app.refreshComboInNodes?.();
|
|
} catch (error) {
|
|
console.warn('[AMB] Import: could not reload the model lists.', error);
|
|
}
|
|
owner.loadFolders?.(); // the type counts, then the cards
|
|
const items = placed.map(item => ({ type: item.type, path_idx: item.root, rel: item.rel, filename: item.file }));
|
|
await startScan(owner, { targets: targetsForItems(items) });
|
|
}
|