- Added new AI_FEATURES.md guide covering all AI-powered capabilities - Updated DOCUMENTATION_INDEX.md to include new AI features guide - Added AI Features section to README.md with overview and models used - Improved backfill warning information in README.md - Updated reset index / delete DB procedures in DB_MAINTENANCE.md - Added configuration options for AI features in README.md
1349 lines
50 KiB
JavaScript
1349 lines
50 KiB
JavaScript
/**
|
|
* API Client - Safe fetch wrapper (never throws)
|
|
*/
|
|
|
|
import { SETTINGS_KEY } from "../app/settingsStore.js";
|
|
import { ENDPOINTS } from "./endpoints.js";
|
|
import { normalizeAssetId, pickRootId } from "../utils/ids.js";
|
|
|
|
/**
|
|
* @template T
|
|
* @typedef {{
|
|
* ok: boolean,
|
|
* data: (T|null),
|
|
* error?: (string|null),
|
|
* code?: string,
|
|
* meta?: any,
|
|
* status?: number
|
|
* }} ApiResult
|
|
*/
|
|
|
|
/**
|
|
* Compact viewer-oriented media info returned by `/mjr/am/viewer/info`.
|
|
*
|
|
* @typedef {{
|
|
* kind?: ('image'|'video'|'unknown'),
|
|
* mime?: (string|null),
|
|
* width?: (number|null),
|
|
* height?: (number|null),
|
|
* fps?: (number|string|null),
|
|
* fps_raw?: (string|null),
|
|
* frame_count?: (number|null),
|
|
* duration_s?: (number|null)
|
|
* }} ViewerInfo
|
|
*/
|
|
|
|
let _obsCache = { value: null, at: 0 };
|
|
let _rtSyncCache = { value: null, at: 0 };
|
|
let _tagsCache = { value: null, at: 0 };
|
|
const AUTH_TOKEN_CACHE_TTL_MS = 2000;
|
|
let _authTokenCache = { value: "", at: 0 };
|
|
const TAGS_CACHE_TTL_MS = 30_000;
|
|
const DEFAULT_TAGS_CACHE_TTL_MS = TAGS_CACHE_TTL_MS;
|
|
const CLIENT_GLOBAL_KEY = "__MJR_API_CLIENT__";
|
|
const RUNTIME_TOKEN_KEY = "__mjr_write_token";
|
|
const SETTINGS_FAST_CACHE_TTL_MS = 2000;
|
|
const MAX_BATCH_ASSET_IDS = 200;
|
|
const WRITE_METHODS = new Set(["POST", "PUT", "DELETE", "PATCH"]);
|
|
const BOOTSTRAP_TOKEN_PATH = "/mjr/am/settings/security/bootstrap-token";
|
|
const DEFAULT_FETCH_TIMEOUT_MS = 20_000;
|
|
const MAX_FETCH_TIMEOUT_MS = 300_000;
|
|
let _authTokenRefreshInFlight = null;
|
|
const VECTOR_BACKFILL_DEFAULT_POLL_INTERVAL_MS = 1000;
|
|
const VECTOR_BACKFILL_DEFAULT_POLL_TIMEOUT_MS = 30 * 60_000;
|
|
const VECTOR_BACKFILL_MAX_POLL_TIMEOUT_MS = 12 * 60 * 60_000;
|
|
|
|
function _methodIsWrite(method) {
|
|
return WRITE_METHODS.has(String(method || "").toUpperCase());
|
|
}
|
|
|
|
function _normalizeUrlPath(url) {
|
|
try {
|
|
const raw = String(url || "").trim();
|
|
if (!raw) return "";
|
|
if (raw.startsWith("http://") || raw.startsWith("https://")) {
|
|
const base =
|
|
(typeof globalThis !== "undefined" && globalThis?.location?.origin)
|
|
? String(globalThis.location.origin)
|
|
: "http://localhost";
|
|
return new URL(raw, base).pathname || "";
|
|
}
|
|
return raw.split("?")[0] || "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function _isMajoorApiUrl(url) {
|
|
const path = _normalizeUrlPath(url);
|
|
return path.startsWith("/mjr/am/");
|
|
}
|
|
|
|
function _isBootstrapTokenUrl(url) {
|
|
return _normalizeUrlPath(url) === BOOTSTRAP_TOKEN_PATH;
|
|
}
|
|
|
|
function _coerceBool(value, fallback = false) {
|
|
if (typeof value === "boolean") return value;
|
|
if (typeof value === "string") {
|
|
const s = value.trim().toLowerCase();
|
|
if (["1", "true", "yes", "on", "enabled", "enable"].includes(s)) return true;
|
|
if (["0", "false", "no", "off", "disabled", "disable"].includes(s)) return false;
|
|
}
|
|
return Boolean(fallback);
|
|
}
|
|
|
|
function _getTagsCacheTTL() {
|
|
try {
|
|
const raw = localStorage?.getItem?.(SETTINGS_KEY) || "{}";
|
|
const parsed = JSON.parse(raw);
|
|
const ttl = parsed?.cache?.tagsTTLms ?? parsed?.cache?.tagsTTL ?? parsed?.cache?.tags_ttl_ms ?? null;
|
|
const n = Number(ttl);
|
|
if (!Number.isFinite(n)) return DEFAULT_TAGS_CACHE_TTL_MS;
|
|
return Math.max(1_000, Math.min(10 * 60_000, Math.floor(n)));
|
|
} catch {
|
|
return DEFAULT_TAGS_CACHE_TTL_MS;
|
|
}
|
|
}
|
|
|
|
function _readAuthToken() {
|
|
const now = Date.now();
|
|
const elapsed = now - (_authTokenCache.at || 0);
|
|
if (elapsed >= 0 && elapsed < AUTH_TOKEN_CACHE_TTL_MS) {
|
|
return _authTokenCache.value;
|
|
}
|
|
|
|
try {
|
|
const sessionToken = String(sessionStorage?.getItem?.(RUNTIME_TOKEN_KEY) || "").trim();
|
|
if (sessionToken) {
|
|
_authTokenCache = { value: sessionToken, at: now };
|
|
return sessionToken;
|
|
}
|
|
} catch (e) { console.debug?.(e); }
|
|
|
|
try {
|
|
const raw = localStorage?.getItem?.(SETTINGS_KEY);
|
|
const parsed = raw ? JSON.parse(raw) : null;
|
|
const payload = parsed?.data && typeof parsed.data === "object" ? parsed.data : parsed;
|
|
const token = String(payload?.security?.apiToken || "").trim();
|
|
if (token) {
|
|
try {
|
|
sessionStorage?.setItem?.(RUNTIME_TOKEN_KEY, token);
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
const mutable = parsed && typeof parsed === "object" ? parsed : {};
|
|
const target = mutable?.data && typeof mutable.data === "object" ? mutable.data : mutable;
|
|
if (target?.security && typeof target.security === "object") {
|
|
target.security.apiToken = "";
|
|
localStorage?.setItem?.(SETTINGS_KEY, JSON.stringify(mutable));
|
|
window?.dispatchEvent?.(new CustomEvent("mjr-settings-changed", { detail: { key: "security.apiToken" } }));
|
|
}
|
|
} catch (e) { console.debug?.(e); }
|
|
}
|
|
_authTokenCache = { value: token, at: now };
|
|
return token;
|
|
} catch {
|
|
_authTokenCache = { value: "", at: now };
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function _persistAuthToken(token) {
|
|
const normalized = String(token || "").trim();
|
|
if (!normalized) return false;
|
|
try {
|
|
sessionStorage?.setItem?.(RUNTIME_TOKEN_KEY, normalized);
|
|
_authTokenCache = { value: normalized, at: Date.now() };
|
|
try {
|
|
const raw = localStorage?.getItem?.(SETTINGS_KEY);
|
|
const parsed = raw ? JSON.parse(raw) : {};
|
|
const next = parsed && typeof parsed === "object" ? parsed : {};
|
|
const target = next?.data && typeof next.data === "object" ? next.data : next;
|
|
if (target?.security && typeof target.security === "object" && String(target.security.apiToken || "").trim()) {
|
|
target.security.apiToken = "";
|
|
localStorage?.setItem?.(SETTINGS_KEY, JSON.stringify(next));
|
|
}
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
window?.dispatchEvent?.(new CustomEvent("mjr-settings-changed", { detail: { key: "security.apiToken" } }));
|
|
} catch (e) { console.debug?.(e); }
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function _refreshAuthTokenFromServer() {
|
|
try {
|
|
const response = await fetch("/mjr/am/settings/security/bootstrap-token", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"X-Requested-With": "XMLHttpRequest"
|
|
},
|
|
body: "{}"
|
|
});
|
|
const contentType = response.headers.get("content-type") || "";
|
|
if (!contentType.includes("application/json")) return false;
|
|
const payload = await response.json().catch(() => null);
|
|
if (!payload || typeof payload !== "object" || !payload.ok) return false;
|
|
const token = String(payload?.data?.token || "").trim();
|
|
if (token) return _persistAuthToken(token);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function ensureWriteAuthToken({ force = false } = {}) {
|
|
const existing = _readAuthToken();
|
|
if (existing && !force) return existing;
|
|
if (!_authTokenRefreshInFlight) {
|
|
_authTokenRefreshInFlight = (async () => {
|
|
try {
|
|
await _refreshAuthTokenFromServer();
|
|
} finally {
|
|
_authTokenRefreshInFlight = null;
|
|
}
|
|
})();
|
|
}
|
|
try {
|
|
await _authTokenRefreshInFlight;
|
|
} catch (e) { console.debug?.(e); }
|
|
return _readAuthToken();
|
|
}
|
|
|
|
const MAX_RETRIES = 3;
|
|
const RETRY_BASE_DELAY_MS = 400;
|
|
|
|
function _delay(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function _isRetryableError(error) {
|
|
try {
|
|
if (!error) return false;
|
|
const name = String(error.name || "");
|
|
if (name === "AbortError") return false;
|
|
const msg = String(error.message || "").toLowerCase();
|
|
// TypeError is often used for network failures ("Failed to fetch"), but can also be real code bugs.
|
|
if (name === "TypeError") {
|
|
return (
|
|
msg.includes("failed to fetch") ||
|
|
msg.includes("networkerror") ||
|
|
msg.includes("load failed") ||
|
|
msg.includes("fetch") ||
|
|
msg.includes("network")
|
|
);
|
|
}
|
|
return msg.includes("fetch") || msg.includes("network") || msg.includes("failed");
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function _resolveFetchTimeoutMs(options = {}) {
|
|
try {
|
|
const raw = Number(options?.timeoutMs);
|
|
if (!Number.isFinite(raw)) return DEFAULT_FETCH_TIMEOUT_MS;
|
|
return Math.max(1_000, Math.min(MAX_FETCH_TIMEOUT_MS, Math.floor(raw)));
|
|
} catch {
|
|
return DEFAULT_FETCH_TIMEOUT_MS;
|
|
}
|
|
}
|
|
|
|
function _buildTimedSignal(options = {}) {
|
|
const upstreamSignal = options?.signal || null;
|
|
if (typeof AbortController === "undefined") {
|
|
return {
|
|
signal: upstreamSignal || undefined,
|
|
timeoutMs: _resolveFetchTimeoutMs(options),
|
|
cleanup: () => {},
|
|
};
|
|
}
|
|
const timeoutMs = _resolveFetchTimeoutMs(options);
|
|
const ctrl = new AbortController();
|
|
let timer = null;
|
|
const onAbort = () => {
|
|
try {
|
|
ctrl.abort();
|
|
} catch (e) { console.debug?.(e); }
|
|
};
|
|
try {
|
|
timer = setTimeout(() => {
|
|
try {
|
|
ctrl.abort();
|
|
} catch (e) { console.debug?.(e); }
|
|
}, timeoutMs);
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
if (upstreamSignal) {
|
|
if (upstreamSignal.aborted) {
|
|
onAbort();
|
|
} else {
|
|
upstreamSignal.addEventListener("abort", onAbort, { once: true });
|
|
}
|
|
}
|
|
} catch (e) { console.debug?.(e); }
|
|
return {
|
|
signal: ctrl.signal,
|
|
timeoutMs,
|
|
cleanup: () => {
|
|
try {
|
|
if (timer) clearTimeout(timer);
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
if (upstreamSignal) upstreamSignal.removeEventListener("abort", onAbort);
|
|
} catch (e) { console.debug?.(e); }
|
|
},
|
|
};
|
|
}
|
|
|
|
function invalidateObsCache() {
|
|
_obsCache = { value: null, at: 0 };
|
|
}
|
|
|
|
function invalidateRatingTagsSyncCache() {
|
|
_rtSyncCache = { value: null, at: 0 };
|
|
}
|
|
|
|
function invalidateTagsCache() {
|
|
_tagsCache = { value: null, at: 0 };
|
|
}
|
|
|
|
function invalidateAuthTokenCache() {
|
|
_authTokenCache = { value: "", at: 0 };
|
|
}
|
|
|
|
// Best-effort cache invalidation when settings change (ComfyUI settings, dev tools, etc.).
|
|
try {
|
|
const w = typeof window !== "undefined" ? window : null;
|
|
if (w && !w[CLIENT_GLOBAL_KEY]) {
|
|
w[CLIENT_GLOBAL_KEY] = { initialized: true };
|
|
|
|
w.addEventListener?.("storage", (event) => {
|
|
try {
|
|
if (event?.key === SETTINGS_KEY) {
|
|
invalidateObsCache();
|
|
invalidateRatingTagsSyncCache();
|
|
invalidateTagsCache();
|
|
invalidateAuthTokenCache();
|
|
}
|
|
} catch (e) { console.debug?.(e); }
|
|
});
|
|
|
|
w.addEventListener?.("mjr-settings-changed", () => {
|
|
invalidateObsCache();
|
|
invalidateRatingTagsSyncCache();
|
|
invalidateTagsCache();
|
|
invalidateAuthTokenCache();
|
|
});
|
|
}
|
|
} catch (e) { console.debug?.(e); }
|
|
|
|
const _readObsEnabled = () => {
|
|
const now = Date.now();
|
|
if (_obsCache.value !== null && now - (_obsCache.at || 0) < SETTINGS_FAST_CACHE_TTL_MS) {
|
|
return _obsCache.value;
|
|
}
|
|
try {
|
|
const raw = localStorage?.getItem?.(SETTINGS_KEY);
|
|
if (!raw) {
|
|
_obsCache = { value: false, at: now };
|
|
return _obsCache.value;
|
|
}
|
|
const parsed = JSON.parse(raw);
|
|
_obsCache = { value: !!parsed?.observability?.enabled, at: now };
|
|
return _obsCache.value;
|
|
} catch {
|
|
_obsCache = { value: false, at: now };
|
|
return _obsCache.value;
|
|
}
|
|
};
|
|
|
|
const _readRatingTagsSyncEnabled = () => {
|
|
const now = Date.now();
|
|
if (_rtSyncCache.value !== null && now - (_rtSyncCache.at || 0) < SETTINGS_FAST_CACHE_TTL_MS) {
|
|
return _rtSyncCache.value;
|
|
}
|
|
try {
|
|
const raw = localStorage?.getItem?.(SETTINGS_KEY);
|
|
if (!raw) {
|
|
_rtSyncCache = { value: true, at: now };
|
|
return _rtSyncCache.value;
|
|
}
|
|
const parsed = JSON.parse(raw);
|
|
const configured = parsed?.ratingTagsSync?.enabled;
|
|
_rtSyncCache = {
|
|
value: configured === undefined || configured === null ? true : _coerceBool(configured, true),
|
|
at: now
|
|
};
|
|
return _rtSyncCache.value;
|
|
} catch {
|
|
_rtSyncCache = { value: true, at: now };
|
|
return _rtSyncCache.value;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Fetch wrapper that always returns {ok, data, error}
|
|
* Never throws - returns error object instead
|
|
*/
|
|
/** @returns {Promise<ApiResult<any>>} */
|
|
async function fetchAPI(url, options = {}, retryCount = 0) {
|
|
// Start API call timing
|
|
const apiStartTime = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
const timed = _buildTimedSignal(options);
|
|
try {
|
|
const headers = typeof Headers !== "undefined" ? new Headers(options.headers || {}) : { ...options.headers };
|
|
const method = (options.method || "GET").toUpperCase();
|
|
const authRetryDone = !!options?._authRetryDone;
|
|
|
|
// Add anti-CSRF header for state-changing requests
|
|
if (_methodIsWrite(method)) {
|
|
try {
|
|
if (headers instanceof Headers) {
|
|
if (!headers.has("X-Requested-With")) headers.set("X-Requested-With", "XMLHttpRequest");
|
|
} else if (!headers["X-Requested-With"]) {
|
|
headers["X-Requested-With"] = "XMLHttpRequest";
|
|
}
|
|
} catch (e) { console.debug?.(e); }
|
|
}
|
|
|
|
// Per-client switch: control backend observability logs.
|
|
// Explicitly send on/off so backend doesn't have to guess.
|
|
const obsEnabled = _readObsEnabled();
|
|
try {
|
|
if (headers instanceof Headers) {
|
|
if (!headers.has("X-MJR-OBS")) headers.set("X-MJR-OBS", obsEnabled ? "on" : "off");
|
|
} else if (!("X-MJR-OBS" in headers)) {
|
|
headers["X-MJR-OBS"] = obsEnabled ? "on" : "off";
|
|
}
|
|
} catch (e) { console.debug?.(e); }
|
|
|
|
let authToken = _readAuthToken();
|
|
if (!authToken && _methodIsWrite(method) && _isMajoorApiUrl(url) && !_isBootstrapTokenUrl(url)) {
|
|
try {
|
|
await ensureWriteAuthToken();
|
|
} catch (e) { console.debug?.(e); }
|
|
authToken = _readAuthToken();
|
|
}
|
|
if (authToken) {
|
|
try {
|
|
if (headers instanceof Headers) {
|
|
if (!headers.has("X-MJR-Token")) headers.set("X-MJR-Token", authToken);
|
|
if (!headers.has("Authorization")) headers.set("Authorization", `Bearer ${authToken}`);
|
|
} else {
|
|
if (!("X-MJR-Token" in headers)) headers["X-MJR-Token"] = authToken;
|
|
if (!("Authorization" in headers)) headers["Authorization"] = `Bearer ${authToken}`;
|
|
}
|
|
} catch (e) { console.debug?.(e); }
|
|
}
|
|
|
|
const fetchOptions = { ...options, headers, signal: timed.signal };
|
|
try {
|
|
delete fetchOptions._authRetryDone;
|
|
delete fetchOptions.timeoutMs;
|
|
} catch (e) { console.debug?.(e); }
|
|
const response = await fetch(url, fetchOptions);
|
|
const contentType = response.headers.get("content-type") || "";
|
|
if (!contentType.includes("application/json")) {
|
|
if (
|
|
!authRetryDone &&
|
|
_methodIsWrite(method) &&
|
|
_isMajoorApiUrl(url) &&
|
|
!_isBootstrapTokenUrl(url) &&
|
|
Number(response.status || 0) === 401
|
|
) {
|
|
const refreshed = await ensureWriteAuthToken({ force: true });
|
|
if (refreshed) {
|
|
const retryOptions = { ...options, _authRetryDone: true };
|
|
return await fetchAPI(url, retryOptions, retryCount);
|
|
}
|
|
}
|
|
return {
|
|
ok: false,
|
|
error: `Server returned non-JSON response (${response.status})`,
|
|
code: "INVALID_RESPONSE",
|
|
status: response.status,
|
|
content_type: contentType,
|
|
data: null
|
|
};
|
|
}
|
|
|
|
const result = await response.json().catch(() => null);
|
|
if (typeof result !== "object" || result === null) {
|
|
return {
|
|
ok: false,
|
|
error: "Invalid response structure",
|
|
code: "INVALID_RESPONSE",
|
|
status: response.status,
|
|
data: null
|
|
};
|
|
}
|
|
|
|
// Preserve HTTP status for callers that want to treat 401/403/404 specially.
|
|
if (!("status" in result)) {
|
|
try {
|
|
result.status = response.status;
|
|
} catch (e) { console.debug?.(e); }
|
|
}
|
|
const shouldTryAuthRefresh =
|
|
!authRetryDone &&
|
|
_methodIsWrite(method) &&
|
|
!_isBootstrapTokenUrl(url) &&
|
|
!result?.ok &&
|
|
(String(result?.code || "").toUpperCase() === "AUTH_REQUIRED" || Number(result?.status || 0) === 401);
|
|
|
|
if (shouldTryAuthRefresh) {
|
|
const refreshed = await ensureWriteAuthToken({ force: true });
|
|
if (refreshed) {
|
|
const retryOptions = { ...options, _authRetryDone: true };
|
|
return await fetchAPI(url, retryOptions, retryCount);
|
|
}
|
|
}
|
|
|
|
return result; // Backend returns {ok, data, error, code, meta}
|
|
} catch (error) {
|
|
try {
|
|
if (String(error?.name || "") === "AbortError") {
|
|
if (options?.signal && options.signal.aborted) {
|
|
return { ok: false, error: "Aborted", code: "ABORTED", data: null };
|
|
}
|
|
return {
|
|
ok: false,
|
|
error: `Request timed out after ${timed.timeoutMs}ms`,
|
|
code: "TIMEOUT",
|
|
data: null,
|
|
timeout_ms: timed.timeoutMs,
|
|
};
|
|
}
|
|
} catch (e) { console.debug?.(e); }
|
|
// Retry network failures a few times (best-effort).
|
|
if (retryCount < MAX_RETRIES && _isRetryableError(error)) {
|
|
try {
|
|
await _delay(RETRY_BASE_DELAY_MS * (retryCount + 1));
|
|
} catch (e) { console.debug?.(e); }
|
|
try {
|
|
return await fetchAPI(url, options, retryCount + 1);
|
|
} catch (e) { console.debug?.(e); }
|
|
}
|
|
return {
|
|
ok: false,
|
|
error: error?.message || String(error || "Network error"),
|
|
code: "NETWORK_ERROR",
|
|
data: null,
|
|
retries: retryCount
|
|
};
|
|
} finally {
|
|
// Track API call timing
|
|
try {
|
|
const apiEndTime = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
const duration = apiEndTime - apiStartTime;
|
|
if (typeof window !== 'undefined' && window.MajoorMetrics) {
|
|
window.MajoorMetrics.trackApiCall(duration, !result?.ok);
|
|
}
|
|
} catch (e) { console.debug?.(e); }
|
|
|
|
try {
|
|
timed.cleanup?.();
|
|
} catch (e) { console.debug?.(e); }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET request helper
|
|
*/
|
|
export async function get(url, options = {}) {
|
|
return fetchAPI(url, { ...options, method: "GET" });
|
|
}
|
|
|
|
/**
|
|
* POST request helper
|
|
*/
|
|
export async function post(url, body, options = {}) {
|
|
return fetchAPI(url, {
|
|
...options,
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", ...options.headers },
|
|
body: JSON.stringify(body)
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Update asset rating (0-5 stars)
|
|
*/
|
|
export async function updateAssetRating(assetId, rating, options = {}) {
|
|
const enabled = _readRatingTagsSyncEnabled();
|
|
const asset = assetId && typeof assetId === "object" ? assetId : null;
|
|
const resolvedId = asset ? asset.id : assetId;
|
|
const payload = {
|
|
rating: Math.max(0, Math.min(5, Number(rating) || 0))
|
|
};
|
|
if (resolvedId != null) {
|
|
payload.asset_id = normalizeAssetId(resolvedId);
|
|
} else if (asset) {
|
|
payload.filepath = asset.filepath || asset.path || asset?.file_info?.filepath || "";
|
|
payload.type = asset.type || "output";
|
|
payload.root_id = pickRootId(asset);
|
|
} else {
|
|
payload.asset_id = normalizeAssetId(resolvedId);
|
|
}
|
|
return fetchAPI("/mjr/am/asset/rating", {
|
|
...options,
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(enabled ? { "X-MJR-RTSYNC": "on" } : {})
|
|
},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Update asset tags
|
|
*/
|
|
export async function updateAssetTags(assetId, tags, options = {}) {
|
|
const enabled = _readRatingTagsSyncEnabled();
|
|
const asset = assetId && typeof assetId === "object" ? assetId : null;
|
|
const resolvedId = asset ? asset.id : assetId;
|
|
const payload = {
|
|
tags: Array.isArray(tags) ? tags : []
|
|
};
|
|
if (resolvedId != null) {
|
|
payload.asset_id = normalizeAssetId(resolvedId);
|
|
} else if (asset) {
|
|
payload.filepath = asset.filepath || asset.path || asset?.file_info?.filepath || "";
|
|
payload.type = asset.type || "output";
|
|
payload.root_id = pickRootId(asset);
|
|
} else {
|
|
payload.asset_id = normalizeAssetId(resolvedId);
|
|
}
|
|
const result = await fetchAPI("/mjr/am/asset/tags", {
|
|
...options,
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(enabled ? { "X-MJR-RTSYNC": "on" } : {})
|
|
},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
if (result?.ok) {
|
|
try {
|
|
if (Array.isArray(_tagsCache.value)) {
|
|
const next = new Set(_tagsCache.value);
|
|
for (const t of Array.isArray(tags) ? tags : []) next.add(t);
|
|
_tagsCache = { value: Array.from(next), at: Date.now() };
|
|
} else {
|
|
invalidateTagsCache();
|
|
}
|
|
} catch {
|
|
invalidateTagsCache();
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get all available tags from the database
|
|
*/
|
|
export async function getAvailableTags() {
|
|
const now = Date.now();
|
|
if (Array.isArray(_tagsCache.value) && now - (_tagsCache.at || 0) < _getTagsCacheTTL()) {
|
|
return { ok: true, data: _tagsCache.value, error: null, code: "OK", meta: { cached: true } };
|
|
}
|
|
|
|
const result = await get("/mjr/am/tags");
|
|
if (result?.ok && Array.isArray(result.data)) {
|
|
_tagsCache = { value: result.data, at: now };
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get full asset metadata by ID
|
|
*/
|
|
export async function getAssetMetadata(assetId, options = {}) {
|
|
return get(`/mjr/am/asset/${encodeURIComponent(normalizeAssetId(assetId))}`, options);
|
|
}
|
|
|
|
/**
|
|
* Get compact viewer media info by asset ID (fps/frame count/dimensions/etc).
|
|
*/
|
|
/** @returns {Promise<ApiResult<ViewerInfo>>} */
|
|
export async function getViewerInfo(assetId, options = {}) {
|
|
const id = normalizeAssetId(assetId);
|
|
if (!id) return { ok: false, data: null, error: "Missing assetId", code: "INVALID_INPUT" };
|
|
let url = `/mjr/am/viewer/info?asset_id=${encodeURIComponent(id)}`;
|
|
if (options.refresh) url += "&refresh=1";
|
|
const { refresh: _ignored, ...fetchOpts } = options;
|
|
return get(url, fetchOpts);
|
|
}
|
|
|
|
/**
|
|
* Batch fetch assets by ID (no per-asset tool invocations / self-heal).
|
|
*/
|
|
export async function getAssetsBatch(assetIds, options = {}) {
|
|
const ids = Array.isArray(assetIds) ? assetIds : [];
|
|
const cleaned = [];
|
|
for (const id of ids) {
|
|
const n = Number(id);
|
|
if (!Number.isFinite(n)) continue;
|
|
cleaned.push(Math.trunc(n));
|
|
if (cleaned.length >= MAX_BATCH_ASSET_IDS) break;
|
|
}
|
|
if (!cleaned.length) return { ok: true, data: [], error: null, code: "OK" };
|
|
return post("/mjr/am/assets/batch", { asset_ids: cleaned }, options);
|
|
}
|
|
|
|
export async function hydrateAssetRatingTags(assetId) {
|
|
const id = normalizeAssetId(assetId);
|
|
if (!id) return { ok: false, error: "Missing assetId" };
|
|
return get(`/mjr/am/asset/${encodeURIComponent(id)}?hydrate=rating_tags`);
|
|
}
|
|
|
|
/**
|
|
* Get metadata for a file reference (preferred over absolute paths).
|
|
* Works on /view URLs where ComfyUI provides type/filename/subfolder.
|
|
*/
|
|
export async function getFileMetadataScoped(
|
|
{ type = "output", filename = "", subfolder = "", root_id = "", rootId = "", filepath = "" } = {},
|
|
options = {}
|
|
) {
|
|
const t = String(type || "output").trim().toLowerCase() || "output";
|
|
const fn = String(filename || "").trim();
|
|
const sub = String(subfolder || "").trim();
|
|
const rid = String(root_id || rootId || "").trim();
|
|
const fp = String(filepath || "").trim();
|
|
if (!fn) return { ok: false, data: null, error: "Missing filename", code: "INVALID_INPUT" };
|
|
let url = `/mjr/am/metadata?type=${encodeURIComponent(t)}&filename=${encodeURIComponent(fn)}`;
|
|
if (fp) url += `&filepath=${encodeURIComponent(fp)}`;
|
|
if (sub) url += `&subfolder=${encodeURIComponent(sub)}`;
|
|
if (rid) url += `&root_id=${encodeURIComponent(rid)}`;
|
|
return get(url, options);
|
|
}
|
|
|
|
export async function getFolderInfo({ filepath = "", root_id = "", subfolder = "" } = {}, options = {}) {
|
|
try {
|
|
if (globalThis.__mjrFolderInfoSupported === false) {
|
|
return { ok: false, data: null, error: "Folder info endpoint unavailable", code: "UNAVAILABLE" };
|
|
}
|
|
if (globalThis.__mjrFolderInfoSupported == null) {
|
|
const rr = await get("/mjr/am/routes");
|
|
if (rr?.ok && Array.isArray(rr.data)) {
|
|
const hasRoute = rr.data.some((r) => String(r?.path || "").trim() === "/mjr/am/folder-info");
|
|
globalThis.__mjrFolderInfoSupported = !!hasRoute;
|
|
if (!hasRoute) {
|
|
return { ok: false, data: null, error: "Folder info endpoint unavailable", code: "UNAVAILABLE" };
|
|
}
|
|
} else {
|
|
// Soft-fail: keep null so future calls can retry route discovery.
|
|
globalThis.__mjrFolderInfoSupported = null;
|
|
}
|
|
}
|
|
} catch (e) { console.debug?.(e); }
|
|
|
|
const fp = String(filepath || "").trim();
|
|
const rid = String(root_id || "").trim();
|
|
const sub = String(subfolder || "").trim();
|
|
let url = ENDPOINTS.FOLDER_INFO;
|
|
const params = [];
|
|
if (fp) {
|
|
params.push(`filepath=${encodeURIComponent(fp)}`);
|
|
params.push("browser_mode=1");
|
|
}
|
|
else {
|
|
if (rid) params.push(`root_id=${encodeURIComponent(rid)}`);
|
|
if (sub) params.push(`subfolder=${encodeURIComponent(sub)}`);
|
|
}
|
|
if (params.length) url += `?${params.join("&")}`;
|
|
const res = await get(url, options);
|
|
try {
|
|
if (!res?.ok && Number(res?.status || 0) === 404) {
|
|
globalThis.__mjrFolderInfoSupported = false;
|
|
}
|
|
} catch (e) { console.debug?.(e); }
|
|
return res;
|
|
}
|
|
|
|
export async function setProbeBackendMode(mode) {
|
|
if (!mode || typeof mode !== "string") {
|
|
return { ok: false, error: "Missing mode", code: "INVALID_INPUT" };
|
|
}
|
|
return post("/mjr/am/settings/probe-backend", { mode });
|
|
}
|
|
|
|
export async function getMetadataFallbackSettings() {
|
|
return get(ENDPOINTS.SETTINGS_METADATA_FALLBACK);
|
|
}
|
|
|
|
export async function setMetadataFallbackSettings({ image, media } = {}) {
|
|
return post(ENDPOINTS.SETTINGS_METADATA_FALLBACK, { image, media });
|
|
}
|
|
|
|
export async function getVectorSearchSettings() {
|
|
return get(ENDPOINTS.SETTINGS_VECTOR_SEARCH);
|
|
}
|
|
|
|
export async function setVectorSearchSettings(enabled = true) {
|
|
return post(ENDPOINTS.SETTINGS_VECTOR_SEARCH, { enabled: !!enabled });
|
|
}
|
|
|
|
export async function getHuggingFaceSettings() {
|
|
return get(ENDPOINTS.SETTINGS_HUGGINGFACE);
|
|
}
|
|
|
|
export async function setHuggingFaceSettings(token = "") {
|
|
return post(ENDPOINTS.SETTINGS_HUGGINGFACE, { token: String(token ?? "").trim() });
|
|
}
|
|
|
|
export async function getAiLoggingSettings() {
|
|
return get(ENDPOINTS.SETTINGS_AI_LOGGING);
|
|
}
|
|
|
|
export async function setAiLoggingSettings(enabled = false) {
|
|
return post(ENDPOINTS.SETTINGS_AI_LOGGING, { enabled: !!enabled });
|
|
}
|
|
|
|
export async function getOutputDirectorySetting() {
|
|
return get(ENDPOINTS.SETTINGS_OUTPUT_DIRECTORY);
|
|
}
|
|
|
|
export async function setOutputDirectorySetting(outputDirectory, options = {}) {
|
|
const value = String(outputDirectory ?? "").trim();
|
|
return post(ENDPOINTS.SETTINGS_OUTPUT_DIRECTORY, { output_directory: value }, options);
|
|
}
|
|
|
|
export async function getSecuritySettings() {
|
|
return get("/mjr/am/settings/security");
|
|
}
|
|
|
|
export async function setSecuritySettings(prefs) {
|
|
const body = prefs && typeof prefs === "object" ? prefs : {};
|
|
return post("/mjr/am/settings/security", body);
|
|
}
|
|
|
|
export async function bootstrapSecurityToken() {
|
|
const res = await post("/mjr/am/settings/security/bootstrap-token", {});
|
|
if (res?.ok) {
|
|
try {
|
|
const token = String(res?.data?.token || "").trim();
|
|
if (token) _persistAuthToken(token);
|
|
} catch (e) { console.debug?.(e); }
|
|
}
|
|
return res;
|
|
}
|
|
|
|
export async function openInFolder(assetOrId) {
|
|
if (assetOrId && typeof assetOrId === "object") {
|
|
const fp = String(assetOrId.filepath || assetOrId.path || assetOrId?.file_info?.filepath || "").trim();
|
|
if (assetOrId.id != null) return post("/mjr/am/open-in-folder", { asset_id: normalizeAssetId(assetOrId.id) });
|
|
return post("/mjr/am/open-in-folder", { filepath: fp });
|
|
}
|
|
return post("/mjr/am/open-in-folder", { asset_id: normalizeAssetId(assetOrId) });
|
|
}
|
|
|
|
export async function browserFolderOp({ op = "", path = "", name = "", destination = "", recursive = true } = {}, options = {}) {
|
|
const body = {
|
|
op: String(op || "").trim().toLowerCase(),
|
|
path: String(path || "").trim(),
|
|
};
|
|
if (name != null && String(name).trim()) body.name = String(name).trim();
|
|
if (destination != null && String(destination).trim()) body.destination = String(destination).trim();
|
|
if (body.op === "delete") body.recursive = !!recursive;
|
|
return post(ENDPOINTS.BROWSER_FOLDER_OP, body, options);
|
|
}
|
|
|
|
export async function resetIndex(options = {}) {
|
|
const _bool = (value, fallback) => (value === undefined || value === null ? fallback : Boolean(value));
|
|
const scope = String(options.scope || "output").trim().toLowerCase() || "output";
|
|
const customRootId =
|
|
options.customRootId ??
|
|
options.custom_root_id ??
|
|
options.rootId ??
|
|
options.root_id ??
|
|
options.customRoot ??
|
|
null;
|
|
const body = {
|
|
scope,
|
|
reindex: _bool(options.reindex, true),
|
|
// When scope=all, the backend defaults to a hard DB reset unless explicitly disabled.
|
|
hard_reset_db: _bool(
|
|
options.hardResetDb ??
|
|
options.hard_reset_db ??
|
|
options.deleteDbFiles ??
|
|
options.delete_db_files ??
|
|
options.deleteDb ??
|
|
options.delete_db ??
|
|
undefined,
|
|
scope === "all"
|
|
),
|
|
clear_scan_journal: _bool(options.clearScanJournal ?? options.clear_scan_journal, true),
|
|
clear_metadata_cache: _bool(options.clearMetadataCache ?? options.clear_metadata_cache, true),
|
|
clear_asset_metadata: _bool(options.clearAssetMetadata ?? options.clear_asset_metadata, true),
|
|
clear_assets: _bool(options.clearAssets ?? options.clear_assets, true),
|
|
preserve_vectors: _bool(
|
|
options.preserveVectors ??
|
|
options.preserve_vectors ??
|
|
options.keepVectors ??
|
|
options.keep_vectors,
|
|
false
|
|
),
|
|
rebuild_fts: _bool(options.rebuildFts ?? options.rebuild_fts, true),
|
|
incremental: _bool(options.incremental, false),
|
|
fast: _bool(options.fast, true),
|
|
background_metadata: _bool(options.backgroundMetadata ?? options.background_metadata, true),
|
|
maintenance_force: _bool(options.maintenanceForce ?? options.maintenance_force, false),
|
|
};
|
|
if (customRootId) {
|
|
body.custom_root_id = String(customRootId);
|
|
}
|
|
return post(ENDPOINTS.INDEX_RESET, body);
|
|
}
|
|
|
|
export async function setWatcherScope({ scope = "output", customRootId = "" } = {}) {
|
|
const s = String(scope || "output").trim().toLowerCase() || "output";
|
|
const rid = String(customRootId || "").trim();
|
|
const body = { scope: s };
|
|
if (rid) body.custom_root_id = rid;
|
|
return post(ENDPOINTS.WATCHER_SCOPE, body);
|
|
}
|
|
|
|
export async function getWatcherStatus(options = {}) {
|
|
return get(ENDPOINTS.WATCHER_STATUS, options);
|
|
}
|
|
|
|
export async function toggleWatcher(enabled = true) {
|
|
return post(ENDPOINTS.WATCHER_TOGGLE, { enabled: !!enabled });
|
|
}
|
|
|
|
export async function getWatcherSettings() {
|
|
return get(ENDPOINTS.WATCHER_SETTINGS);
|
|
}
|
|
|
|
export async function updateWatcherSettings(payload = {}) {
|
|
return post(ENDPOINTS.WATCHER_SETTINGS, payload);
|
|
}
|
|
|
|
export async function getToolsStatus(options = {}) {
|
|
return get(ENDPOINTS.TOOLS_STATUS, options);
|
|
}
|
|
|
|
export async function getRuntimeStatus(options = {}) {
|
|
return get(ENDPOINTS.STATUS, options);
|
|
}
|
|
|
|
/**
|
|
* Emergency force-delete the SQLite database and recreate it.
|
|
* Bypasses DB-dependent security checks (works even when DB is corrupted).
|
|
* Closes connections, force-removes DB files from disk, reinitializes, and triggers a rescan.
|
|
*/
|
|
export async function forceDeleteDb() {
|
|
return post("/mjr/am/db/force-delete", {});
|
|
}
|
|
|
|
export async function listDbBackups(options = {}) {
|
|
return get(ENDPOINTS.DB_BACKUPS, options);
|
|
}
|
|
|
|
export async function saveDbBackup() {
|
|
return post(ENDPOINTS.DB_BACKUP_SAVE, {});
|
|
}
|
|
|
|
export async function restoreDbBackup({ name = "", useLatest = false } = {}) {
|
|
const body = {};
|
|
if (name) body.name = String(name);
|
|
if (useLatest) body.use_latest = true;
|
|
return post(ENDPOINTS.DB_BACKUP_RESTORE, body);
|
|
}
|
|
|
|
export async function startDuplicatesAnalysis(limit = 250) {
|
|
return post("/mjr/am/duplicates/analyze", { limit: Math.max(10, Math.min(5000, Number(limit) || 250)) });
|
|
}
|
|
|
|
export async function getDuplicateAlerts(
|
|
{ scope = "output", customRootId = "", maxGroups = 6, maxPairs = 10 } = {},
|
|
options = {}
|
|
) {
|
|
let url = `/mjr/am/duplicates/alerts?scope=${encodeURIComponent(String(scope || "output"))}`;
|
|
if (customRootId) {
|
|
url += `&custom_root_id=${encodeURIComponent(String(customRootId))}`;
|
|
}
|
|
url += `&max_groups=${encodeURIComponent(String(Math.max(1, Number(maxGroups) || 6)))}`;
|
|
url += `&max_pairs=${encodeURIComponent(String(Math.max(1, Number(maxPairs) || 10)))}`;
|
|
return get(url, options);
|
|
}
|
|
|
|
export async function mergeDuplicateTags(keepAssetId, mergeAssetIds = []) {
|
|
return post("/mjr/am/duplicates/merge-tags", {
|
|
keep_asset_id: Number(keepAssetId) || 0,
|
|
merge_asset_ids: Array.isArray(mergeAssetIds) ? mergeAssetIds.map((x) => Number(x) || 0).filter((x) => x > 0) : []
|
|
});
|
|
}
|
|
|
|
export async function deleteAsset(assetOrId) {
|
|
let id = "";
|
|
let payload = null;
|
|
if (assetOrId && typeof assetOrId === "object") {
|
|
id = normalizeAssetId(assetOrId.id);
|
|
const fp = String(assetOrId.filepath || assetOrId.path || assetOrId?.file_info?.filepath || "").trim();
|
|
payload = id ? { asset_id: id } : { filepath: fp };
|
|
} else {
|
|
id = normalizeAssetId(assetOrId);
|
|
payload = { asset_id: id };
|
|
}
|
|
const res = await post("/mjr/am/asset/delete", payload);
|
|
if (res?.ok && id) _emitAssetsDeleted([id]);
|
|
return res;
|
|
}
|
|
|
|
export async function deleteAssets(assetIds) {
|
|
const ids = Array.isArray(assetIds) ? assetIds.map((x) => normalizeAssetId(x)).filter(Boolean) : [];
|
|
const res = await post("/mjr/am/assets/delete", { ids });
|
|
if (res?.ok) _emitAssetsDeleted(ids);
|
|
return res;
|
|
}
|
|
|
|
function _emitAssetsDeleted(ids) {
|
|
try {
|
|
const normalized = (Array.isArray(ids) ? ids : [ids])
|
|
.map((x) => String(x || "").trim())
|
|
.filter(Boolean);
|
|
if (!normalized.length) return;
|
|
window.dispatchEvent(new CustomEvent("mjr:assets-deleted", { detail: { ids: normalized } }));
|
|
} catch (e) { console.debug?.(e); }
|
|
}
|
|
|
|
export async function renameAsset(assetOrId, newName) {
|
|
let id = "";
|
|
if (assetOrId && typeof assetOrId === "object") {
|
|
id = normalizeAssetId(assetOrId.id);
|
|
const fp = String(assetOrId.filepath || assetOrId.path || assetOrId?.file_info?.filepath || "").trim();
|
|
const res = id
|
|
? await post("/mjr/am/asset/rename", { asset_id: id, new_name: newName })
|
|
: await post("/mjr/am/asset/rename", { filepath: fp, new_name: newName });
|
|
if (res?.ok && id) {
|
|
try {
|
|
const fresh = await getAssetMetadata(id);
|
|
if (fresh?.ok && fresh?.data) {
|
|
res.data = { ...(res.data || {}), asset: fresh.data };
|
|
}
|
|
} catch (e) { console.debug?.(e); }
|
|
}
|
|
return res;
|
|
}
|
|
id = normalizeAssetId(assetOrId);
|
|
const res = await post("/mjr/am/asset/rename", { asset_id: id, new_name: newName });
|
|
if (res?.ok && id) {
|
|
try {
|
|
const fresh = await getAssetMetadata(id);
|
|
if (fresh?.ok && fresh?.data) {
|
|
res.data = { ...(res.data || {}), asset: fresh.data };
|
|
}
|
|
} catch (e) { console.debug?.(e); }
|
|
}
|
|
return res;
|
|
}
|
|
|
|
// -----------------------------
|
|
// Collections
|
|
// -----------------------------
|
|
|
|
export async function listCollections() {
|
|
const ac = typeof AbortController !== "undefined" ? new AbortController() : null;
|
|
let timer = null;
|
|
try {
|
|
if (ac) timer = setTimeout(() => ac.abort(), 10_000);
|
|
return await get("/mjr/am/collections", ac ? { signal: ac.signal } : {});
|
|
} finally {
|
|
if (timer) clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
export async function createCollection(name) {
|
|
return post("/mjr/am/collections", { name: String(name || "").trim() });
|
|
}
|
|
|
|
export async function deleteCollection(collectionId) {
|
|
const id = String(collectionId || "").trim();
|
|
return post(`/mjr/am/collections/${encodeURIComponent(id)}/delete`, {});
|
|
}
|
|
|
|
export async function addAssetsToCollection(collectionId, assets) {
|
|
const id = String(collectionId || "").trim();
|
|
const list = Array.isArray(assets) ? assets : [];
|
|
return post(`/mjr/am/collections/${encodeURIComponent(id)}/add`, { assets: list });
|
|
}
|
|
|
|
export async function removeFilepathsFromCollection(collectionId, filepaths) {
|
|
const id = String(collectionId || "").trim();
|
|
const list = Array.isArray(filepaths) ? filepaths : [];
|
|
return post(`/mjr/am/collections/${encodeURIComponent(id)}/remove`, { filepaths: list });
|
|
}
|
|
|
|
export async function getCollectionAssets(collectionId) {
|
|
const id = String(collectionId || "").trim();
|
|
return get(`/mjr/am/collections/${encodeURIComponent(id)}/assets`);
|
|
}
|
|
|
|
// ── Vector / Semantic Search ──────────────────────────────────────────
|
|
|
|
/**
|
|
* Semantic search by natural-language query via SigLIP2 embeddings.
|
|
* @param {string} query
|
|
* @param {number|{topK?:number, scope?:string, customRootId?:string}} [topKOrOptions=20]
|
|
* @returns {Promise<ApiResult<{asset_id:number, score:number}[]>>}
|
|
*/
|
|
export async function vectorSearch(query, topKOrOptions = 20) {
|
|
const q = String(query || "").trim();
|
|
if (!q) return { ok: false, error: "Empty query" };
|
|
const opts = (topKOrOptions && typeof topKOrOptions === "object")
|
|
? topKOrOptions
|
|
: { topK: Number(topKOrOptions) };
|
|
const topK = Math.max(1, Math.min(200, Number(opts?.topK ?? 20) || 20));
|
|
const scope = String(opts?.scope || "").trim();
|
|
const customRootId = String(opts?.customRootId || "").trim();
|
|
let url = `${ENDPOINTS.VECTOR_SEARCH}?q=${encodeURIComponent(q)}&top_k=${topK}`;
|
|
if (scope) url += `&scope=${encodeURIComponent(scope)}`;
|
|
if (customRootId) url += `&custom_root_id=${encodeURIComponent(customRootId)}`;
|
|
// Model cold-start (SigLIP download/load) can take 60-120s on first use
|
|
return get(url, { timeoutMs: 120_000 });
|
|
}
|
|
|
|
/**
|
|
* Find visually similar assets to a given asset.
|
|
* @param {number|string} assetId
|
|
* @param {number|{topK?:number, scope?:string, customRootId?:string}} [topKOrOptions=20]
|
|
* @returns {Promise<ApiResult<{asset_id:number, score:number}[]>>}
|
|
*/
|
|
export async function vectorFindSimilar(assetId, topKOrOptions = 20) {
|
|
const id = String(assetId || "").trim();
|
|
if (!id) return { ok: false, error: "Missing asset ID" };
|
|
const opts = (topKOrOptions && typeof topKOrOptions === "object")
|
|
? topKOrOptions
|
|
: { topK: Number(topKOrOptions) };
|
|
const topK = Math.max(1, Math.min(200, Number(opts?.topK ?? 20) || 20));
|
|
const scope = String(opts?.scope || "").trim();
|
|
const customRootId = String(opts?.customRootId || "").trim();
|
|
let url = `${ENDPOINTS.VECTOR_SIMILAR}/${encodeURIComponent(id)}?top_k=${topK}`;
|
|
if (scope) url += `&scope=${encodeURIComponent(scope)}`;
|
|
if (customRootId) url += `&custom_root_id=${encodeURIComponent(customRootId)}`;
|
|
return get(url);
|
|
}
|
|
|
|
/**
|
|
* Retrieve the prompt-alignment score for an asset.
|
|
* Returns { ok: true, data: number|null } (0.0-1.0, null if N/A).
|
|
* @param {number|string} assetId
|
|
* @returns {Promise<ApiResult<number|null>>}
|
|
*/
|
|
export async function vectorGetAlignment(assetId) {
|
|
const id = String(assetId || "").trim();
|
|
if (!id) return { ok: false, error: "Missing asset ID" };
|
|
return get(`${ENDPOINTS.VECTOR_ALIGNMENT}/${encodeURIComponent(id)}`);
|
|
}
|
|
|
|
/**
|
|
* Force re-index a single asset's vector embedding.
|
|
* @param {number|string} assetId
|
|
* @returns {Promise<ApiResult>}
|
|
*/
|
|
export async function vectorIndexAsset(assetId) {
|
|
const id = String(assetId || "").trim();
|
|
if (!id) return { ok: false, error: "Missing asset ID" };
|
|
return post(`${ENDPOINTS.VECTOR_INDEX}/${encodeURIComponent(id)}`, {});
|
|
}
|
|
|
|
/**
|
|
* Retrieve vector index stats.
|
|
* @returns {Promise<ApiResult<{total:number, avg_score:number|null, model:string}>>}
|
|
*/
|
|
export async function vectorStats() {
|
|
return get(ENDPOINTS.VECTOR_STATS);
|
|
}
|
|
|
|
/**
|
|
* Backfill missing vector embeddings for already indexed assets.
|
|
* @param {number} [batchSize=64] Batch size (1-200)
|
|
* @param {{onProgress?:(status:Object)=>void, scope?:string, customRootId?:string, custom_root_id?:string}} [options]
|
|
* @returns {Promise<ApiResult<{processed:number, indexed:number, skipped:number}>>}
|
|
*/
|
|
export async function vectorBackfill(batchSize = 64, options = {}) {
|
|
const batch = Math.max(1, Math.min(200, batchSize));
|
|
const onProgress = typeof options?.onProgress === "function" ? options.onProgress : null;
|
|
const scope = String(options?.scope || "").trim().toLowerCase();
|
|
const customRootId = String(options?.customRootId ?? options?.custom_root_id ?? "").trim();
|
|
let startUrl = `${ENDPOINTS.VECTOR_BACKFILL}?batch_size=${batch}&async=1`;
|
|
if (scope) startUrl += `&scope=${encodeURIComponent(scope)}`;
|
|
if (customRootId) startUrl += `&custom_root_id=${encodeURIComponent(customRootId)}`;
|
|
const startRes = await post(startUrl, {}, { timeoutMs: 30_000 });
|
|
if (!startRes?.ok) return startRes;
|
|
|
|
const startData = startRes?.data || {};
|
|
const status = String(startData?.status || "").toLowerCase();
|
|
const jobId = String(startData?.job_id || "").trim();
|
|
try { onProgress?.(startData); } catch (e) { console.debug?.(e); }
|
|
|
|
// Backward compatibility with older backend behavior (sync payload).
|
|
if (!jobId || !["queued", "running", "pending"].includes(status)) {
|
|
return startRes;
|
|
}
|
|
|
|
const pollIntervalMsRaw = Number(options?.pollIntervalMs);
|
|
const pollTimeoutMsRaw = Number(options?.pollTimeoutMs);
|
|
const pollIntervalMs = Number.isFinite(pollIntervalMsRaw)
|
|
? Math.max(500, Math.min(10_000, Math.floor(pollIntervalMsRaw)))
|
|
: VECTOR_BACKFILL_DEFAULT_POLL_INTERVAL_MS;
|
|
const pollTimeoutMs = Number.isFinite(pollTimeoutMsRaw)
|
|
? Math.max(10_000, Math.min(VECTOR_BACKFILL_MAX_POLL_TIMEOUT_MS, Math.floor(pollTimeoutMsRaw)))
|
|
: VECTOR_BACKFILL_DEFAULT_POLL_TIMEOUT_MS;
|
|
const startedAt = Date.now();
|
|
let lastStatus = null;
|
|
|
|
while (Date.now() - startedAt < pollTimeoutMs) {
|
|
await _delay(pollIntervalMs);
|
|
const pollRes = await get(`${ENDPOINTS.VECTOR_BACKFILL_STATUS}?job_id=${encodeURIComponent(jobId)}`, { timeoutMs: 30_000 });
|
|
if (!pollRes?.ok) {
|
|
lastStatus = pollRes;
|
|
continue;
|
|
}
|
|
|
|
const data = pollRes?.data || {};
|
|
const st = String(data?.status || "").toLowerCase();
|
|
lastStatus = pollRes;
|
|
try { onProgress?.(data); } catch (e) { console.debug?.(e); }
|
|
|
|
if (st === "succeeded") {
|
|
return {
|
|
ok: true,
|
|
data: data?.result || {},
|
|
code: null,
|
|
status: 200,
|
|
};
|
|
}
|
|
|
|
if (st === "failed") {
|
|
return {
|
|
ok: false,
|
|
error: String(data?.error || "Vector backfill failed"),
|
|
code: String(data?.code || "DB_ERROR"),
|
|
data,
|
|
status: 500,
|
|
};
|
|
}
|
|
}
|
|
|
|
const finalStatusRes = await get(`${ENDPOINTS.VECTOR_BACKFILL_STATUS}?job_id=${encodeURIComponent(jobId)}`, { timeoutMs: 30_000 });
|
|
const finalData = finalStatusRes?.data || lastStatus?.data || {};
|
|
const finalState = String(finalData?.status || "").toLowerCase();
|
|
if (finalStatusRes?.ok && ["queued", "running", "pending"].includes(finalState)) {
|
|
try { onProgress?.(finalData); } catch (e) { console.debug?.(e); }
|
|
return {
|
|
ok: true,
|
|
code: "PENDING",
|
|
status: 202,
|
|
data: {
|
|
...finalData,
|
|
pending: true,
|
|
timed_out: true,
|
|
poll_timeout_ms: pollTimeoutMs,
|
|
job_id: String(finalData?.job_id || jobId),
|
|
status: finalState || "running",
|
|
},
|
|
meta: { pending: true },
|
|
};
|
|
}
|
|
|
|
if (finalStatusRes?.ok && finalState === "failed") {
|
|
return {
|
|
ok: false,
|
|
error: String(finalData?.error || "Vector backfill failed"),
|
|
code: String(finalData?.code || "DB_ERROR"),
|
|
data: finalData,
|
|
status: 500,
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: false,
|
|
error: `Vector backfill polling timed out after ${pollTimeoutMs}ms`,
|
|
code: "TIMEOUT",
|
|
data: finalData || null,
|
|
status: 408,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Retrieve AI-suggested (auto-tag) tags for an asset — separate from user tags.
|
|
* @param {number|string} assetId
|
|
* @returns {Promise<ApiResult<string[]>>}
|
|
*/
|
|
export async function vectorGetAutoTags(assetId) {
|
|
const id = String(assetId || "").trim();
|
|
if (!id) return { ok: false, error: "Missing asset ID" };
|
|
return get(`${ENDPOINTS.VECTOR_AUTO_TAGS}/${encodeURIComponent(id)}`);
|
|
}
|
|
|
|
/**
|
|
* Generate and persist a Florence-2 caption for an image asset.
|
|
* @param {number|string} assetId
|
|
* @returns {Promise<ApiResult<string>>}
|
|
*/
|
|
export async function vectorGenerateCaption(assetId) {
|
|
const id = String(assetId || "").trim();
|
|
if (!id) return { ok: false, error: "Missing asset ID" };
|
|
return post(`${ENDPOINTS.VECTOR_CAPTION}/${encodeURIComponent(id)}`, {});
|
|
}
|
|
|
|
/**
|
|
* Backward-compatible alias for previous naming.
|
|
* @deprecated Use vectorGenerateCaption
|
|
*/
|
|
export async function vectorGenerateEnhancedPrompt(assetId) {
|
|
return vectorGenerateCaption(assetId);
|
|
}
|
|
|
|
/**
|
|
* Hybrid FTS + semantic search (Google-like).
|
|
* Supports inline filters: rating:N tag:X kind:image after:YYYY-MM-DD ext:png
|
|
* @param {string} query Raw search query (filters parsed server-side)
|
|
* @param {Object} [params]
|
|
* @param {number} [params.topK=50]
|
|
* @param {string} [params.scope="output"]
|
|
* @param {string} [params.customRootId]
|
|
* @returns {Promise<ApiResult<Array>>}
|
|
*/
|
|
export async function hybridSearch(query, { topK = 50, scope = "output", customRootId = "" } = {}) {
|
|
const q = String(query || "").trim();
|
|
if (!q) return { ok: false, error: "Empty query" };
|
|
let url = `${ENDPOINTS.HYBRID_SEARCH}?q=${encodeURIComponent(q)}&top_k=${Math.max(1, Math.min(200, topK))}&scope=${encodeURIComponent(scope)}`;
|
|
if (customRootId) url += `&custom_root_id=${encodeURIComponent(customRootId)}`;
|
|
// Model cold-start (SigLIP download/load) can take 60-120s on first use
|
|
return get(url, { timeoutMs: 120_000 });
|
|
}
|
|
|
|
/**
|
|
* Fetch assets flagged by the library audit (missing tags, low alignment, etc.).
|
|
* @param {Object} [params]
|
|
* @param {string} [params.filter="incomplete"] "incomplete"|"low_alignment"|"no_tags"|"no_rating"|"no_workflow"
|
|
* @param {string} [params.sort="alignment_asc"] "alignment_asc"|"alignment_desc"|"completeness_asc"|"newest"|"oldest"
|
|
* @param {string} [params.scope="output"]
|
|
* @param {string} [params.customRootId]
|
|
* @param {number} [params.limit=200]
|
|
* @returns {Promise<ApiResult<Array>>}
|
|
*/
|
|
export async function getAuditAssets({ filter = "incomplete", sort = "alignment_asc", scope = "output", customRootId = "", limit = 200 } = {}) {
|
|
let url = `${ENDPOINTS.AUDIT}?filter=${encodeURIComponent(filter)}&sort=${encodeURIComponent(sort)}&scope=${encodeURIComponent(scope)}&limit=${Math.max(1, Math.min(500, limit))}`;
|
|
if (customRootId) url += `&custom_root_id=${encodeURIComponent(customRootId)}`;
|
|
return get(url);
|
|
}
|
|
|
|
/**
|
|
* Cluster all indexed embeddings into suggested collections.
|
|
* @param {number} [k=8] Number of clusters
|
|
* @returns {Promise<ApiResult<Array<{cluster_id:number, label:string, size:number, sample_assets:Array, dominant_tags:string[]}>>>}
|
|
*/
|
|
export async function vectorSuggestCollections(k = 8) {
|
|
return post(ENDPOINTS.VECTOR_SUGGEST_COLLECTIONS, { k: Math.max(2, Math.min(20, k)) });
|
|
}
|