The models page's Tidy chip (with a count) reads every model's header (model_kind) and lists files whose type does not match their folder - such as an Anima/Flux/Wan diffusion model in checkpoints, which Civitai labels "Checkpoint" - with why it matters, where it belongs (same subfolder, same drive) and Move there, taking its cover, info and notes along; never over a file. A whole checkpoint in diffusion models is marked as loading anyway. Identical files are grouped by SHA-256 (scan hashes; same-size unscanned files on Compare); each copy can go to the Recycle Bin. Moves are logged (model_move). Also: opening the Tidy or Sources view stops cards of an earlier load from being drawn into it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
111 lines
5.2 KiB
JavaScript
111 lines
5.2 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;
|
|
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) });
|
|
}
|