thr3shr / frontend /src /api.js
Dinamush
refactor: rebrand project from Thresh3r to THR3SHR
c47eba8
Raw
History Blame Contribute Delete
24.5 kB
const API_BASE = "http://127.0.0.1:8000/api";
const STORAGE_KEY = "thr3shrMockStateV1";
const defaultSettings = {
root_repo: "",
categories_root: "",
confidence_threshold: 0.6,
default_migrate_mode: "copy",
scan_recursive: true,
experimental_media_enabled: false,
experimental_style_detector_enabled: false,
hybrid_ml_on_review: true,
tagging_domain: "drawn",
sfw_classify_mode: false,
selected_tags: [],
selected_tags_nsfw: [],
max_inference_workers: 2,
inference_batch_size: 8,
force_cpu_inference: false,
tagger_model: "wd_swinv2_v3",
wd_general_threshold: 0.35,
};
const mockTags = [
"1girl",
"solo",
"blush",
"black_hair",
"brown_hair",
"blue_eyes",
"short_hair",
"long_hair",
"twintails",
"smile",
"open_mouth",
"looking_at_viewer",
];
const mockState = loadMockState();
let backendAvailable = null;
const REQUEST_TIMEOUT_MS = 30000;
function loadMockState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return { settings: { ...defaultSettings }, nextRunId: 1, runs: {} };
}
const parsed = JSON.parse(raw);
return {
settings: { ...defaultSettings, ...(parsed.settings || {}) },
nextRunId: parsed.nextRunId || 1,
runs: parsed.runs || {},
};
} catch (_) {
return { settings: { ...defaultSettings }, nextRunId: 1, runs: {} };
}
}
function persistMockState() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(mockState));
}
async function jsonRequest(path, options = {}) {
const controller = new AbortController();
const timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : REQUEST_TIMEOUT_MS;
const { timeoutMs: _ignored, ...fetchOptions } = options;
const timeoutId = setTimeout(() => controller.abort("timeout"), timeoutMs);
try {
const response = await fetch(`${API_BASE}${path}`, {
headers: { "Content-Type": "application/json", ...(fetchOptions.headers || {}) },
...fetchOptions,
signal: fetchOptions.signal || controller.signal,
});
const text = await response.text();
const maybeJson = text ? (() => {
try {
return JSON.parse(text);
} catch (_) {
return null;
}
})() : null;
if (!response.ok) {
const detail =
(maybeJson && (maybeJson.detail || maybeJson.message)) || text || "Request failed";
throw new Error(`HTTP ${response.status} ${response.statusText}: ${detail}`);
}
return maybeJson ?? {};
} catch (err) {
if (err?.name === "AbortError") {
throw new Error(`Request timeout after ${timeoutMs / 1000}s`);
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
async function request(path, options = {}) {
if (backendAvailable === false) {
return mockRequest(path, options);
}
try {
const data = await jsonRequest(path, options);
backendAvailable = true;
return data;
} catch (err) {
const message = String(err?.message || "");
const networkError =
err?.name === "TypeError" ||
message.includes("Failed to fetch") ||
message.includes("NetworkError") ||
message.includes("Load failed") ||
message.includes("CORS request did not succeed") ||
message.includes("timeout");
if (backendAvailable === null && networkError) {
backendAvailable = false;
return mockRequest(path, options);
}
throw err;
}
}
function parseBody(options) {
if (!options.body) return {};
return JSON.parse(options.body);
}
function mockCounts(items) {
const byStatus = {};
for (const item of items) {
byStatus[item.status] = (byStatus[item.status] || 0) + 1;
}
return Object.entries(byStatus).map(([status, count]) => ({ status, count }));
}
function makeMockItems(runId, selectedFolders, threshold) {
const pool = selectedFolders.length ? selectedFolders : ["1girl", "solo", "blush"];
return pool.slice(0, 8).map((tag, idx) => {
const score = Math.max(0.3, 0.9 - idx * 0.06);
const needsReview = score < threshold;
return {
id: runId * 1000 + idx + 1,
run_id: runId,
file_path: `/mock/images/sample_${idx + 1}.jpg`,
relative_path: `sample_${idx + 1}.jpg`,
primary_tag: tag,
primary_score: score,
secondary_suggestions: pool
.filter((x) => x !== tag)
.slice(0, 3)
.map((x, i) => ({ tag: x, score: Math.max(0.2, score - 0.1 - i * 0.05) })),
global_top_tags: [tag, ...pool.filter((x) => x !== tag)]
.slice(0, 5)
.map((x, i) => ({ tag: x, score: Math.max(0.15, score - i * 0.07) })),
suggested_destination: `${mockState.settings.categories_root || "/mock/categories"}/${tag}`,
final_tag: tag,
final_destination: `${mockState.settings.categories_root || "/mock/categories"}/${tag}`,
status: needsReview ? "proposed" : "approved",
needs_review: needsReview,
review_reason: needsReview ? `Below threshold (${score.toFixed(3)} < ${threshold.toFixed(3)})` : null,
migrated_to: null,
full_scores: Object.fromEntries(
[tag, ...pool.filter((x) => x !== tag)].map((name, i) => [
name,
Math.max(0.1, score - i * 0.08),
])
),
};
});
}
function computeMockStatus(run) {
const total = run.total_images || 0;
const processed = run.processed_images || 0;
const pct = total > 0 ? Math.min(100, (processed / total) * 100) : 0;
return {
run_id: run.id,
status: run.status,
total_images: total,
processed_images: processed,
failed_images: run.failed_images || 0,
progress_pct: pct,
started_at: run.started_at || null,
finished_at: run.finished_at || null,
last_error: run.last_error || null,
cancel_requested: Boolean(run.cancel_requested),
has_items: (run.items || []).length > 0,
inference_mode: "single",
batch_size: 1,
avg_infer_ms_per_image: run.status === "running" ? 120 : null,
eta_seconds_remaining:
run.status === "running" && total > processed
? Math.max(1, (total - processed) * 0.12)
: null,
eta_finish_at:
run.status === "running" && total > processed
? new Date(Date.now() + Math.max(1, (total - processed) * 120)).toISOString()
: null,
queue_seed: null,
tagger_model: run.tagger_model || mockState.settings.tagger_model || "wd_swinv2_v3",
};
}
function mockRequest(path, options = {}) {
const method = (options.method || "GET").toUpperCase();
const body = parseBody(options);
if (path === "/settings" && method === "GET") {
return Promise.resolve(mockState.settings);
}
if (path === "/providers" && method === "GET") {
return Promise.resolve({
available_providers: ["CPUExecutionProvider"],
cuda_available: false,
cpu_available: true,
forced_cpu: false,
likely_device: "cpu",
cuda_usable: false,
tagger_model: mockState.settings.tagger_model || "wd_swinv2_v3",
loaded_models: [],
mock_mode: true,
});
}
if (path === "/models/unload" && method === "POST") {
return Promise.resolve({
unloaded: [],
message: "No tagger sessions were resident in GPU memory.",
});
}
if (path === "/debug/sfw-sources" && method === "GET") {
return Promise.resolve({
sources: [
{
id: "safebooru",
label: "Safebooru",
sfw_policy: "rating:safe",
max_content_tags: null,
},
{
id: "danbooru",
label: "Danbooru",
sfw_policy: "rating:g",
max_content_tags: 2,
},
],
});
}
if (path === "/debug/sfw-eval" && method === "POST") {
const tags = Array.isArray(body.tags) ? body.tags.filter(Boolean) : [];
if (!tags.length) {
return Promise.reject(new Error("At least one tag is required"));
}
const source = body.source || "safebooru";
if (source === "danbooru" && tags.length > 2) {
return Promise.reject(new Error("Danbooru allows at most 2 content tag(s)"));
}
const count = Math.max(5, Math.min(30, Number(body.count) || 10));
const threshold = Number(mockState.settings.confidence_threshold || 0.6);
const items = Array.from({ length: Math.min(count, 6) }, (_, idx) => {
const pullScores = Object.fromEntries(tags.map((t) => [t, Math.max(0.2, 0.95 - idx * 0.08)]));
const primary = tags[0] || "1girl";
const score = pullScores[primary];
const needsReview = score < threshold;
return {
source,
post_id: String(9000 + idx),
rating: source === "danbooru" ? "g" : "safe",
file_name: `${9000 + idx}.jpg`,
known_tags: tags,
pull_tag_scores: pullScores,
global_top_tags: tags.map((t) => ({ tag: t, score: pullScores[t] })),
primary_tag: needsReview ? null : primary,
primary_score: needsReview ? null : score,
needs_review: needsReview,
review_reason: needsReview ? `Below threshold (${score.toFixed(3)} < ${threshold}).` : null,
suggested_folder: needsReview ? null : primary,
secondary_suggestions: needsReview ? [{ tag: primary, score }] : [],
};
});
return Promise.resolve({
source,
source_label: source === "danbooru" ? "Danbooru" : "Safebooru",
sfw_policy: source === "danbooru" ? "rating:g" : "rating:safe",
query: `${tags.join(" ")} ${source === "danbooru" ? "rating:g" : "rating:safe"}`,
tags,
count_requested: count,
count_evaluated: items.length,
tagger_model: mockState.settings.tagger_model || "wd_swinv2_v3",
confidence_threshold: threshold,
destination_tags: mockState.settings.selected_tags || [],
recall: tags.map((tag) => ({
tag,
present_in_posts: items.length,
hits_at_threshold: items.filter((it) => Number(it.pull_tag_scores[tag] || 0) >= threshold)
.length,
hit_rate: items.length
? items.filter((it) => Number(it.pull_tag_scores[tag] || 0) >= threshold).length /
items.length
: null,
})),
items,
errors: [],
});
}
if (path === "/scan/preview" && method === "GET") {
return Promise.resolve({
root_repo: mockState.settings.root_repo || "/mock/images",
recursive: Boolean(mockState.settings.scan_recursive),
experimental_media_enabled: Boolean(mockState.settings.experimental_media_enabled),
excluded_dirs: mockState.settings.categories_root
? [mockState.settings.categories_root]
: [],
stats: {
total_files: 12,
eligible_images: 8,
ignored_unsupported: 3,
ignored_gif: 1,
failed_to_read: 0,
},
sample_paths: ["/mock/images/sample_1.jpg"],
});
}
if (path.startsWith("/tags") && method === "GET") {
const queryString = path.includes("?") ? path.split("?")[1] : "";
const params = new URLSearchParams(queryString);
const query = (params.get("query") || "").toLowerCase();
const limit = Number(params.get("limit") || 50);
const items = mockTags.filter((t) => t.toLowerCase().includes(query)).slice(0, limit);
return Promise.resolve({ items, count: items.length });
}
if (path === "/settings" && method === "PUT") {
const next = { ...defaultSettings, ...mockState.settings, ...body };
if (next.tagging_domain === "real_life") {
next.sfw_classify_mode = false;
}
if (next.sfw_classify_mode) {
next.selected_tags = ["SFW", "scenery"];
}
if (!Array.isArray(next.selected_tags_nsfw)) {
next.selected_tags_nsfw = [];
}
mockState.settings = next;
persistMockState();
return Promise.resolve(mockState.settings);
}
if (path === "/runs/start" && method === "POST") {
mockState.settings = { ...mockState.settings, ...body };
const runId = mockState.nextRunId++;
const selectedFolders = body.selected_folders || [];
const threshold = Number(mockState.settings.confidence_threshold || 0.6);
const items = makeMockItems(runId, selectedFolders, threshold);
const mappings = selectedFolders.map((name) => ({
folder_name: name,
normalized_name: name.toLowerCase(),
matched_tag: name.toLowerCase(),
matched: true,
}));
mockState.runs[runId] = {
id: runId,
root_repo: mockState.settings.root_repo,
categories_root: mockState.settings.categories_root,
confidence_threshold: threshold,
tagger_model: mockState.settings.tagger_model || "wd_swinv2_v3",
status: "pending",
total_images: items.length,
processed_images: 0,
failed_images: 0,
cancel_requested: 0,
started_at: new Date().toISOString(),
finished_at: null,
last_error: null,
items,
};
persistMockState();
return Promise.resolve({
run_id: runId,
status: "pending",
stats: null,
mappings,
unmatched_folders: [],
created_items: 0,
message: "Run queued; poll /api/runs/{run_id}/status for progress.",
});
}
if (path.match(/^\/runs\/\d+\/status$/) && method === "GET") {
const runId = Number(path.split("/")[2]);
const run = mockState.runs[runId];
if (!run) {
return Promise.reject(new Error("Run not found"));
}
if (run.status === "pending") {
run.status = "running";
}
if (run.status === "running" && !run.cancel_requested) {
run.processed_images = Math.min(run.total_images, run.processed_images + 5);
if (run.processed_images >= run.total_images) {
run.status = "completed";
run.finished_at = new Date().toISOString();
}
persistMockState();
}
if (run.cancel_requested && run.status === "running") {
run.status = "cancelled";
run.finished_at = new Date().toISOString();
persistMockState();
}
return Promise.resolve(computeMockStatus(run));
}
if (path.match(/^\/runs\/\d+\/cancel$/) && method === "POST") {
const runId = Number(path.split("/")[2]);
const run = mockState.runs[runId];
if (!run) {
return Promise.reject(new Error("Run not found"));
}
run.cancel_requested = 1;
if (run.status === "pending") {
run.status = "cancelled";
run.finished_at = new Date().toISOString();
}
persistMockState();
return Promise.resolve(computeMockStatus(run));
}
if (path.match(/^\/runs\/\d+\/reclassify$/) && method === "POST") {
const runId = Number(path.split("/")[2]);
const run = mockState.runs[runId];
if (!run) {
return Promise.reject(new Error("Run not found"));
}
if (["pending", "running"].includes(run.status)) {
return Promise.reject(new Error("Cannot reclassify while a run is still pending or running."));
}
const threshold = Number(run.confidence_threshold || mockState.settings.confidence_threshold || 0.6);
const model = body.tagger_model || "wd_eva02_large";
const idFilter = Array.isArray(body.item_ids) ? new Set(body.item_ids) : null;
let eligibleCount = 0;
run.items = run.items.map((item) => {
if (item.status !== "proposed" || !item.needs_review) return item;
if (idFilter && !idFilter.has(item.id)) return item;
eligibleCount += 1;
const boosted = Math.min(0.99, Number(item.primary_score || 0.4) + 0.35);
const tag = item.primary_tag || item.secondary_suggestions?.[0]?.tag || "1girl";
if (boosted >= threshold) {
return {
...item,
primary_tag: tag,
primary_score: boosted,
needs_review: false,
review_reason: null,
status: "approved",
final_tag: item.final_tag || tag,
suggested_destination: `${mockState.settings.categories_root || "/mock/categories"}/${tag}`,
};
}
return {
...item,
review_reason: `Reclassified with ${model}. Below threshold (${boosted.toFixed(3)} < ${threshold.toFixed(3)}).`,
};
});
if (eligibleCount === 0) {
return Promise.reject(new Error("No eligible needs-review items to reclassify"));
}
run.status = "running";
run.cancel_requested = 0;
run.finished_at = null;
persistMockState();
setTimeout(() => {
if (run.cancel_requested) {
run.status = "cancelled";
} else {
run.status = "completed";
}
run.finished_at = new Date().toISOString();
persistMockState();
}, 400);
return Promise.resolve({
run_id: runId,
status: "running",
eligible_count: eligibleCount,
tagger_model: model,
message: `Reclassify queued for ${eligibleCount} item(s) with ${model}`,
});
}
if (path.startsWith("/runs/") && path.endsWith("/items") && method === "GET") {
const runId = Number(path.split("/")[2]);
const run = mockState.runs[runId];
if (!run) return Promise.resolve([]);
const visibleCount = Math.max(0, run.processed_images || 0);
return Promise.resolve(run.items.slice(0, visibleCount));
}
if (path.startsWith("/runs/") && !path.includes("/items") && method === "GET") {
const runId = Number(path.split("/")[2]);
const run = mockState.runs[runId];
if (!run) return Promise.resolve({ run: null, counts: [] });
const visibleItems = run.items.slice(0, Math.max(0, run.processed_images || 0));
return Promise.resolve({ run, counts: mockCounts(visibleItems) });
}
if (path.startsWith("/items/") && method === "PATCH") {
const itemId = Number(path.split("/")[2]);
const run = Object.values(mockState.runs).find((r) => r.items.some((i) => i.id === itemId));
if (!run) return Promise.resolve(null);
const item = run.items.find((i) => i.id === itemId);
if (body.status) item.status = body.status;
if (body.final_tag) {
item.final_tag = body.final_tag;
item.final_destination = `${mockState.settings.categories_root || "/mock/categories"}/${body.final_tag}`;
}
item.needs_review = false;
item.review_reason = null;
persistMockState();
return Promise.resolve(item);
}
if (path.match(/^\/items\/\d+\/scores$/) && method === "GET") {
const itemId = Number(path.split("/")[2]);
const run = Object.values(mockState.runs).find((r) => r.items.some((i) => i.id === itemId));
if (!run) return Promise.reject(new Error("Item not found"));
const item = run.items.find((i) => i.id === itemId);
return Promise.resolve({ item_id: item.id, full_scores: item.full_scores || {} });
}
if (path.match(/^\/runs\/\d+\/batch$/) && method === "POST") {
const runId = Number(path.split("/")[2]);
const run = mockState.runs[runId];
if (!run) return Promise.resolve({ updated: 0 });
let updated = 0;
for (const item of run.items) {
if (!body.item_ids.includes(item.id)) continue;
if (body.status) item.status = body.status;
if (body.final_tag) {
item.final_tag = body.final_tag;
item.final_destination = `${mockState.settings.categories_root || "/mock/categories"}/${body.final_tag}`;
}
item.needs_review = false;
item.review_reason = null;
updated += 1;
}
persistMockState();
return Promise.resolve({ updated });
}
if (path.match(/^\/runs\/\d+\/migrate$/) && method === "POST") {
const runId = Number(path.split("/")[2]);
const run = mockState.runs[runId];
if (!run) return Promise.resolve({ mode: body.mode, total_candidates: 0, migrated_count: 0, failed_count: 0, results: [] });
const approved = run.items.filter((x) => x.status === "approved");
const results = approved.map((item) => {
item.status = "migrated";
item.migrated_to = `${item.final_destination}/${item.relative_path}`;
return {
item_id: item.id,
source: item.file_path,
destination: item.migrated_to,
success: true,
error: null,
};
});
persistMockState();
return Promise.resolve({
mode: body.mode,
total_candidates: approved.length,
migrated_count: approved.length,
failed_count: 0,
results,
});
}
return Promise.reject(new Error(`Unsupported mock route: ${method} ${path}`));
}
export const api = {
isOfflineMode: () => backendAvailable === false,
getTags: (query = "", limit = 50, domain = null) => {
const params = new URLSearchParams({ query, limit: String(limit) });
if (domain) params.set("domain", domain);
return request(`/tags?${params.toString()}`);
},
getSettings: () => request("/settings"),
getProviders: () => request("/providers"),
unloadModels: () =>
request("/models/unload", {
method: "POST",
body: JSON.stringify({}),
}),
getRealLifeStatus: () => request("/real-life/status"),
previewScan: () => request("/scan/preview"),
saveSettings: (payload) =>
request("/settings", {
method: "PUT",
body: JSON.stringify(payload),
}),
startRun: (payload) =>
request("/runs/start", {
method: "POST",
body: JSON.stringify(payload),
}),
getRun: (runId) => request(`/runs/${runId}`),
getRunStatus: (runId) => request(`/runs/${runId}/status`),
cancelRun: (runId) =>
request(`/runs/${runId}/cancel`, {
method: "POST",
body: JSON.stringify({}),
}),
reclassifyRun: (runId, payload) =>
request(`/runs/${runId}/reclassify`, {
method: "POST",
body: JSON.stringify(payload),
}),
getSfwDebugSources: () => request("/debug/sfw-sources"),
runSfwDebugEval: (payload) =>
request("/debug/sfw-eval", {
method: "POST",
body: JSON.stringify(payload),
timeoutMs: 180000,
}),
getSfwDebugPreviewUrl: (sourceId, fileName) => {
if (backendAvailable === false) return null;
return `${API_BASE}/debug/sfw-eval/preview/${encodeURIComponent(sourceId)}/${encodeURIComponent(fileName)}`;
},
runRealismDebugEval: (payload) =>
request("/debug/realism-eval", {
method: "POST",
body: JSON.stringify(payload),
// Remote download + multi-model GPU scoring can take a long time.
timeoutMs: 60 * 60 * 1000,
}),
getRealismDebugPreviewUrl: (sourceId, fileName) => {
if (backendAvailable === false) return null;
return `${API_BASE}/debug/realism-eval/preview/${encodeURIComponent(sourceId)}/${encodeURIComponent(fileName)}`;
},
getStyleDetectors: () => request("/debug/style-detectors"),
runStyleDebugEval: (payload) =>
request("/debug/style-eval", {
method: "POST",
body: JSON.stringify(payload),
timeoutMs: 60 * 60 * 1000,
}),
runTagRecallEval: (payload) =>
request("/debug/tag-recall-eval", {
method: "POST",
body: JSON.stringify(payload),
// Suite download + dual-model dense scoring can take a while.
timeoutMs: 60 * 60 * 1000,
}),
getTagRecallPreviewUrl: (sourceId, fileName) => {
if (backendAvailable === false) return null;
return `${API_BASE}/debug/tag-recall-eval/preview/${encodeURIComponent(sourceId)}/${encodeURIComponent(fileName)}`;
},
runTagFpEval: (payload) =>
request("/debug/tag-fp-eval", {
method: "POST",
body: JSON.stringify(payload),
timeoutMs: 60 * 60 * 1000,
}),
getRunItems: (runId, filters = {}) => {
const params = new URLSearchParams();
if (filters.status) params.set("status", filters.status);
if (filters.needs_review !== undefined) {
params.set("needs_review", String(filters.needs_review));
}
if (filters.include_scores) {
params.set("include_scores", "true");
}
const query = params.toString();
return request(`/runs/${runId}/items${query ? `?${query}` : ""}`, {
timeoutMs: Number(filters.timeoutMs) > 0 ? Number(filters.timeoutMs) : 120000,
});
},
getItemScores: (itemId) => request(`/items/${itemId}/scores`),
updateItem: (itemId, payload) =>
request(`/items/${itemId}`, {
method: "PATCH",
body: JSON.stringify(payload),
}),
batchUpdate: (runId, payload) =>
request(`/runs/${runId}/batch`, {
method: "POST",
body: JSON.stringify(payload),
}),
migrateRun: (runId, payload) =>
request(`/runs/${runId}/migrate`, {
method: "POST",
body: JSON.stringify(payload),
// Large approved sets can take many minutes (copy/move + DB updates).
timeoutMs: 60 * 60 * 1000,
}),
getItemPreviewUrl: (itemId) => {
if (backendAvailable === false) return null;
return `${API_BASE}/items/${itemId}/preview`;
},
};