Files
DemonGatanjieuandClaude Opus 5.5 3227a75c73 Add "Change type" for a model, and check copies at start, middle and end
- 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>
2026-10-23 21:44:59 +08:00

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) });
}