Files

591 lines
19 KiB
TypeScript

/**
* API Client - Safe fetch wrapper (never throws)
*/
import { SETTINGS_KEY } from "../app/settingsStore.js";
import {
buildWorkflowContentURL,
buildWorkflowDiffURL,
buildWorkflowValidateURL,
buildWorkflowVersionsURL,
ENDPOINTS,
} from "./endpoints.js";
import { createApiFetchClient } from "./fetchUtils.js";
import { normalizeAssetId, pickRootId } from "../utils/ids.js";
import { createTTLCache } from "../utils/ttlCache.js";
import {
readAuthToken as _readAuthToken,
ensureWriteAuthToken,
normalizeWriteAuthFailure as _normalizeWriteAuthFailure,
invalidateAuthTokenCache,
hasRuntimeSecurityToken,
setRuntimeSecurityToken,
} from "./clientAuth.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'|'audio'|'model3d'|'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),
* loader?: (string|null),
* previewable?: (boolean|null),
* interactive?: (boolean|null),
* resource_endpoint?: (string|null)
* }} ViewerInfo
*/
export { setRuntimeSecurityToken, ensureWriteAuthToken, hasRuntimeSecurityToken };
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 SETTINGS_FAST_CACHE_TTL_MS = 2000;
const MAX_BATCH_ASSET_IDS = 200;
export const VECTOR_BACKFILL_DEFAULT_POLL_INTERVAL_MS = 1000;
export const VECTOR_BACKFILL_DEFAULT_POLL_TIMEOUT_MS = 30 * 60_000;
export const VECTOR_BACKFILL_MAX_POLL_TIMEOUT_MS = 12 * 60 * 60_000;
const SETTINGS_CACHE_KEY = "settings";
const TAGS_CACHE_KEY = "available-tags";
const _obsCache = createTTLCache({ ttlMs: SETTINGS_FAST_CACHE_TTL_MS, maxSize: 1 });
const _rtSyncCache = createTTLCache({ ttlMs: SETTINGS_FAST_CACHE_TTL_MS, maxSize: 1 });
const _tagsCache = createTTLCache({ ttlMs: () => _getTagsCacheTTL(), maxSize: 1 });
const TRUE_VALUES = new Set(["1", "true", "yes", "on"]);
const FALSE_VALUES = new Set(["0", "false", "no", "off"]);
function _coerceBool(value: any, fallback = false) {
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (typeof value === "string") {
const s = value.trim().toLowerCase();
if (TRUE_VALUES.has(s)) return true;
if (FALSE_VALUES.has(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 invalidateObsCache() {
_obsCache.clear();
}
function invalidateRatingTagsSyncCache() {
_rtSyncCache.clear();
}
function invalidateTagsCache() {
_tagsCache.clear();
}
function _normalizeTagCacheKey(raw: any) {
const value = String(raw ?? "")
.trim()
.toLowerCase();
return value || "";
}
function _dedupeTagList(tags: any) {
const next = [];
const seen = new Set();
for (const raw of Array.isArray(tags) ? tags : []) {
const value = String(raw ?? "").trim();
if (!value) continue;
const key = _normalizeTagCacheKey(value);
if (!key || seen.has(key)) continue;
seen.add(key);
next.push(value);
}
return next;
}
// Best-effort cache invalidation when settings change (ComfyUI settings, dev tools, etc.).
try {
const w = typeof window !== "undefined" ? window : null;
if (w && !(w as any)[CLIENT_GLOBAL_KEY]) {
(w as any)[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 cached = _obsCache.get(SETTINGS_CACHE_KEY);
if (cached !== undefined) {
return cached;
}
const now = Date.now();
try {
const raw = localStorage?.getItem?.(SETTINGS_KEY);
if (!raw) {
_obsCache.set(SETTINGS_CACHE_KEY, false, { at: now });
return false;
}
const parsed = JSON.parse(raw);
const value = !!parsed?.observability?.enabled;
_obsCache.set(SETTINGS_CACHE_KEY, value, { at: now });
return value;
} catch {
_obsCache.set(SETTINGS_CACHE_KEY, false, { at: now });
return false;
}
};
const _readRatingTagsSyncEnabled = () => {
const cached = _rtSyncCache.get(SETTINGS_CACHE_KEY);
if (cached !== undefined) {
return cached;
}
const now = Date.now();
try {
const raw = localStorage?.getItem?.(SETTINGS_KEY);
if (!raw) {
_rtSyncCache.set(SETTINGS_CACHE_KEY, true, { at: now });
return true;
}
const parsed = JSON.parse(raw);
const configured = parsed?.ratingTagsSync?.enabled;
const value =
configured === undefined || configured === null ? true : _coerceBool(configured, true);
_rtSyncCache.set(SETTINGS_CACHE_KEY, value, { at: now });
return value;
} catch {
_rtSyncCache.set(SETTINGS_CACHE_KEY, true, { at: now });
return true;
}
};
const _apiFetchClient = createApiFetchClient({
readObsEnabled: _readObsEnabled as () => boolean,
readAuthToken: _readAuthToken as () => string,
ensureWriteAuthToken: ensureWriteAuthToken as () => Promise<string>,
normalizeWriteAuthFailure: _normalizeWriteAuthFailure,
});
const fetchAPI = _apiFetchClient.fetchAPI;
export async function get(url: string, options: Record<string, any> = {}) {
return _apiFetchClient.get(url, options);
}
export async function post(url: string, body: unknown, options: Record<string, any> = {}) {
return _apiFetchClient.post(url, body, options);
}
/**
* Update asset rating (0-5 stars)
*/
export async function updateAssetRating(assetId: any, rating: any, options: Record<string, any> = {}) {
const enabled = _readRatingTagsSyncEnabled();
const asset = assetId && typeof assetId === "object" ? assetId : null;
const resolvedId = asset ? asset.id : assetId;
const normalizedId = normalizeAssetId(resolvedId);
const payload: Record<string, any> = {
rating: Math.max(0, Math.min(5, Number(rating) || 0)),
};
if (normalizedId) {
payload.asset_id = normalizedId;
} else if (asset) {
payload.filepath = asset.filepath || asset.path || asset?.file_info?.filepath || "";
payload.type = asset.type || "output";
payload.root_id = pickRootId(asset);
}
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: any, tags: any, options: Record<string, any> = {}) {
const enabled = _readRatingTagsSyncEnabled();
const asset = assetId && typeof assetId === "object" ? assetId : null;
const resolvedId = asset ? asset.id : assetId;
const normalizedId = normalizeAssetId(resolvedId);
const isWorkflowAsset = String(asset?.kind || asset?.type || "").trim().toLowerCase() === "workflow";
const workflowFilepath = String(asset?.filepath || asset?.path || asset?.file_info?.filepath || "").trim();
const payload: Record<string, any> = {
tags: Array.isArray(tags) ? tags : [],
};
if (isWorkflowAsset && workflowFilepath) {
payload.filepath = workflowFilepath;
} else if (normalizedId) {
payload.asset_id = normalizedId;
} else if (asset) {
payload.filepath = asset.filepath || asset.path || asset?.file_info?.filepath || "";
payload.type = asset.type || "output";
payload.root_id = pickRootId(asset);
}
const endpoint = isWorkflowAsset && workflowFilepath ? ENDPOINTS.WORKFLOWS_TAGS : "/mjr/am/asset/tags";
const result = await fetchAPI(endpoint, {
...options,
method: "POST",
headers: {
"Content-Type": "application/json",
...(enabled ? { "X-MJR-RTSYNC": "on" } : {}),
},
body: JSON.stringify(payload),
});
if (result?.ok) {
invalidateTagsCache();
}
return result;
}
/**
* Get all available tags from the database
*/
export async function getAvailableTags() {
const cachedTags = _tagsCache.get(TAGS_CACHE_KEY);
if (Array.isArray(cachedTags)) {
return { ok: true, data: cachedTags, error: null, code: "OK", meta: { cached: true } };
}
const result = await get("/mjr/am/tags");
if (result?.ok && Array.isArray(result.data)) {
const deduped = _dedupeTagList(result.data);
_tagsCache.set(TAGS_CACHE_KEY, deduped);
return { ...result, data: deduped };
}
return result;
}
/**
* Get full asset metadata by ID
*/
export async function getAssetMetadata(assetId: any, options: Record<string, any> = {}) {
const id = encodeURIComponent(normalizeAssetId(assetId));
return get(`/mjr/am/asset/${id}`, {
...options,
dedupeKey: options?.dedupeKey || `meta:${id}`,
});
}
/**
* Get compact viewer media info by asset ID (fps/frame count/dimensions/etc).
*/
/** @returns {Promise<ApiResult<ViewerInfo>>} */
export async function getViewerInfo(assetId: any, options: Record<string, any> = {}) {
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: any, options: Record<string, any> = {}) {
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 getWorkflowContent(filepath: any, options: Record<string, any> = {}) {
const url = buildWorkflowContentURL(filepath);
if (!url) {
return { ok: false, data: null, error: "Missing workflow filepath", code: "INVALID_INPUT" };
}
return get(url, options);
}
export async function validateWorkflow(filepath: any, options: Record<string, any> = {}) {
const url = buildWorkflowValidateURL(filepath);
if (!url) {
return { ok: false, data: null, error: "Missing workflow filepath", code: "INVALID_INPUT" };
}
return get(url, options);
}
export async function listWorkflowVersions(filepath: any, options: Record<string, any> = {}) {
const url = buildWorkflowVersionsURL(filepath);
if (!url) {
return { ok: false, data: null, error: "Missing workflow filepath", code: "INVALID_INPUT" };
}
return get(url, options);
}
export async function diffWorkflow(filepath: any, version_filepath: any = "", options: Record<string, any> = {}) {
const url = buildWorkflowDiffURL(filepath, version_filepath);
if (!url) {
return { ok: false, data: null, error: "Missing workflow filepath", code: "INVALID_INPUT" };
}
return get(url, options);
}
export async function saveWorkflow(
{
workflow = null,
name = "",
category = "",
overwrite = false,
filepath = "",
task = "",
model_family = "",
provider = "",
runs_on = "",
notes = "",
}: Record<string, any> = {},
options: Record<string, any> = {},
) {
return post(
ENDPOINTS.WORKFLOWS_SAVE,
{ workflow, name, category, overwrite, filepath, task, model_family, provider, runs_on, notes },
options,
);
}
export async function duplicateWorkflow(
{ filepath = "", name = "" }: Record<string, any> = {},
options: Record<string, any> = {},
) {
return post(ENDPOINTS.WORKFLOWS_DUPLICATE, { filepath, name }, options);
}
export async function moveWorkflow(
{ filepath = "", name = "", category = "" }: Record<string, any> = {},
options: Record<string, any> = {},
) {
return post(ENDPOINTS.WORKFLOWS_MOVE, { filepath, name, category }, options);
}
export async function deleteWorkflow(
{ filepath = "" }: Record<string, any> = {},
options: Record<string, any> = {},
) {
return post(ENDPOINTS.WORKFLOWS_DELETE, { filepath }, options);
}
export async function markWorkflowLoaded(
{ filepath = "" }: Record<string, any> = {},
options: Record<string, any> = {},
) {
return post(ENDPOINTS.WORKFLOWS_MARK_LOADED, { filepath }, options);
}
export async function setWorkflowFavorite(
{ filepath = "", favorite = false }: Record<string, any> = {},
options: Record<string, any> = {},
) {
return post(ENDPOINTS.WORKFLOWS_FAVORITE, { filepath, favorite: !!favorite }, options);
}
export async function setWorkflowInfo(
{
filepath = "",
task = "",
model_family = "",
provider = "",
runs_on = "",
notes = "",
}: Record<string, any> = {},
options: Record<string, any> = {},
) {
return post(ENDPOINTS.WORKFLOWS_INFO, { filepath, task, model_family, provider, runs_on, notes }, options);
}
export async function listWorkflowThumbnailCandidates(
{ filepath = "", limit = 12 }: Record<string, any> = {},
options: Record<string, any> = {},
) {
const safeLimit = Math.max(1, Math.min(50, Number(limit) || 12));
const url = `${ENDPOINTS.WORKFLOWS_THUMBNAIL_CANDIDATES}?filepath=${encodeURIComponent(
String(filepath || "").trim(),
)}&limit=${encodeURIComponent(String(safeLimit))}`;
return get(url, options);
}
export async function listWorkflowModelFamilies(options: Record<string, any> = {}) {
return get(ENDPOINTS.WORKFLOWS_MODEL_FAMILIES, options);
}
export async function listWorkflows(
{ q = "*", limit = 100, offset = 0, sort = "mtime" }: Record<string, any> = {},
options: Record<string, any> = {},
) {
const safeLimit = Math.max(1, Math.min(500, Number(limit) || 100));
const safeOffset = Math.max(0, Number(offset) || 0);
const url = `${ENDPOINTS.LIST}?scope=workflow&q=${encodeURIComponent(
String(q || "*"),
)}&limit=${encodeURIComponent(String(safeLimit))}&offset=${encodeURIComponent(
String(safeOffset),
)}&sort=${encodeURIComponent(String(sort || "mtime"))}`;
return get(url, options);
}
export async function setWorkflowThumbnail(
{ filepath = "", source_filepath = "" }: Record<string, any> = {},
options: Record<string, any> = {},
) {
return post(ENDPOINTS.WORKFLOWS_THUMBNAIL_SET, { filepath, source_filepath }, options);
}
export async function hydrateAssetRatingTags(assetId: any) {
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 = "" }: Record<string, any> = {},
options: Record<string, any> = {},
) {
try {
if ((globalThis as any).__mjrFolderInfoSupported === false) {
return {
ok: false,
data: null,
error: "Folder info endpoint unavailable",
code: "UNAVAILABLE",
};
}
if ((globalThis as any).__mjrFolderInfoSupported == null) {
const rr = await get("/mjr/am/routes");
if (rr?.ok && Array.isArray(rr.data)) {
const hasRoute = rr.data.some(
(r: any) => String(r?.path || "").trim() === "/mjr/am/folder-info",
);
(globalThis as any).__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 as any).__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 as any).__mjrFolderInfoSupported = false;
}
} catch (e) {
console.debug?.(e);
}
return res;
}
export * from "./clientOps.js";