feat: add stats API endpoints and frontend JS with proxy injection

- Add /api/lm-extra/fetch-stats (POST) endpoint that bulk-fetches CivitAI
  stats for all remote models and stores them in the local stats DB with
  optional WebSocket progress broadcasting
- Add /api/lm-extra/stats-status (GET) endpoint to check DB population
- Add /lm-extra/stats-ui.js handler that serves the client-side JS file
- Create static/lm_stats_ui.js: intercepts list API responses to capture
  stats, patches model cards with download/rating/likes badges, adds
  CivitAI Stats sort options to the dropdown, and adds a Fetch Stats
  toolbar button
- Inject stats-ui.js script tag into proxied HTML page responses

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 14:39:56 +01:00
parent 1ce1e7d48a
commit 467aea0f47
2 changed files with 335 additions and 0 deletions
+112
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
from pathlib import Path
import aiohttp import aiohttp
from aiohttp import web, WSMsgType from aiohttp import web, WSMsgType
@@ -190,6 +191,8 @@ _SEND_SYNC_HANDLERS = {
# Stats enrichment # Stats enrichment
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_STATS_JS_PATH = Path(__file__).resolve().parent / "static" / "lm_stats_ui.js"
_stats_db = None _stats_db = None
@@ -333,6 +336,93 @@ async def _proxy_list_with_stats_sort(
}) })
# ---------------------------------------------------------------------------
# Extra API endpoints (stats)
# ---------------------------------------------------------------------------
async def _handle_fetch_stats(request: web.Request) -> web.Response:
"""Bulk fetch CivitAI stats for all models."""
try:
from .stats_service import StatsFetchService
except ImportError:
from stats_service import StatsFetchService
db = _get_stats_db()
# Get the lora list from remote
session = await _get_proxy_session()
remote_url = f"{remote_config.remote_url}/api/lm/loras/list?page=1&page_size=9999"
try:
async with session.get(remote_url) as resp:
if resp.status != 200:
return web.json_response(
{"success": False, "error": "Failed to fetch lora list"},
status=500,
)
data = await resp.json()
except Exception as exc:
return web.json_response(
{"success": False, "error": f"Failed to fetch lora list: {exc}"},
status=500,
)
items = data.get("items", [])
models = []
for item in items:
civitai = item.get("civitai") or {}
model_id = civitai.get("modelId")
sha256 = item.get("sha256")
if model_id and sha256:
models.append({"sha256": sha256, "civitai_model_id": model_id})
if not models:
return web.json_response({"success": True, "updated": 0,
"message": "No models with CivitAI IDs found"})
service = StatsFetchService(db, api_key=remote_config.civitai_api_key or None)
try:
try:
server = _get_prompt_server()
async def progress(current, total):
server.send_sync("lm_stats_progress", {
"current": current, "total": total,
})
updated = await service.fetch_stats_for_models(models, progress_callback=progress)
except Exception:
updated = await service.fetch_stats_for_models(models)
finally:
await service.close()
return web.json_response({"success": True, "updated": updated, "total": len(models)})
async def _handle_stats_status(request: web.Request) -> web.Response:
"""Check if stats DB is populated."""
db = _get_stats_db()
return web.json_response({"success": True, "count": db.count()})
async def _handle_stats_ui_js(request: web.Request) -> web.Response:
"""Serve the stats UI JavaScript file."""
try:
content = _STATS_JS_PATH.read_text(encoding="utf-8")
return web.Response(
text=content,
content_type="application/javascript",
)
except Exception as exc:
return web.Response(status=404, text=str(exc))
_EXTRA_API_HANDLERS = {
"/api/lm-extra/fetch-stats": _handle_fetch_stats,
"/api/lm-extra/stats-status": _handle_stats_status,
"/lm-extra/stats-ui.js": _handle_stats_ui_js,
}
# Shared HTTP session for proxied requests (connection pooling) # Shared HTTP session for proxied requests (connection pooling)
_proxy_session: aiohttp.ClientSession | None = None _proxy_session: aiohttp.ClientSession | None = None
@@ -485,6 +575,11 @@ async def lm_remote_proxy_middleware(request: web.Request, handler):
if _is_ws_route(path): if _is_ws_route(path):
return await _proxy_ws(request) return await _proxy_ws(request)
# Extra API endpoints (stats, etc.)
extra_handler = _EXTRA_API_HANDLERS.get(path)
if extra_handler is not None:
return await extra_handler(request)
# Stats-based sorting: intercept list requests, fetch all from remote, # Stats-based sorting: intercept list requests, fetch all from remote,
# sort locally, paginate locally # sort locally, paginate locally
if (_should_proxy(path) and path.endswith("/list") if (_should_proxy(path) and path.endswith("/list")
@@ -506,6 +601,23 @@ async def lm_remote_proxy_middleware(request: web.Request, handler):
return web.json_response(enriched) return web.json_response(enriched)
except Exception: except Exception:
pass # Return original response on any error pass # Return original response on any error
# Inject stats UI script into HTML pages
if (path in _PROXY_PAGE_ROUTES or path.rstrip("/") in _PROXY_PAGE_ROUTES):
if response.status == 200:
content_type = response.headers.get("Content-Type", "")
if "text/html" in content_type:
try:
html = response.body.decode("utf-8")
inject = '<script src="/lm-extra/stats-ui.js"></script>'
html = html.replace("</body>", f"{inject}\n</body>")
return web.Response(
text=html,
content_type="text/html",
charset="utf-8",
status=response.status,
)
except Exception:
pass
return response return response
# Not a LoRA Manager route — fall through # Not a LoRA Manager route — fall through
+223
View File
@@ -0,0 +1,223 @@
// static/lm_stats_ui.js
/**
* CivitAI Stats UI — card badges, sort dropdown options, fetch button.
*
* Injected into LoRA Manager pages by the LM-Remote proxy.
* Reads stats from enriched /api/lm/loras/list responses and
* patches the DOM to show badges, sort options, and a fetch button.
*/
(function () {
"use strict";
// ── Compact number formatting ──────────────────────────────────
function formatCompact(n) {
if (n == null) return null;
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, "") + "k";
return String(n);
}
// ── Badge creation ─────────────────────────────────────────────
function createStatBadge(icon, value, title) {
if (value == null) return null;
const badge = document.createElement("span");
badge.className = "lm-stat-badge";
badge.title = title;
badge.innerHTML = `<i class="fas fa-${icon}"></i> ${formatCompact(value)}`;
return badge;
}
// ── Inject CSS ─────────────────────────────────────────────────
function injectStyles() {
if (document.getElementById("lm-stats-styles")) return;
const style = document.createElement("style");
style.id = "lm-stats-styles";
style.textContent = `
.lm-stat-badges {
display: flex;
gap: 6px;
flex-wrap: wrap;
padding: 2px 6px;
}
.lm-stat-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
padding: 1px 5px;
border-radius: 4px;
background: rgba(255,255,255,0.1);
color: rgba(255,255,255,0.8);
white-space: nowrap;
}
.lm-stat-badge i {
font-size: 10px;
opacity: 0.7;
}
`;
document.head.appendChild(style);
}
// ── Intercept list API to capture stats ────────────────────────
const _statsMap = {};
const _origFetch = window.fetch;
window.fetch = async function (...args) {
const response = await _origFetch.apply(this, args);
const url = typeof args[0] === "string" ? args[0] : args[0]?.url;
if (url && url.includes("/api/lm/") && url.includes("/list")) {
try {
const clone = response.clone();
const data = await clone.json();
(data.items || []).forEach((item) => {
if (item.sha256 && item.download_count != null) {
_statsMap[item.sha256] = {
download_count: item.download_count,
rating: item.rating,
rating_count: item.rating_count,
thumbs_up_count: item.thumbs_up_count,
};
}
});
} catch (e) { /* ignore parse errors */ }
}
return response;
};
// ── Patch model cards ──────────────────────────────────────────
function patchCards() {
const cards = document.querySelectorAll(".model-card:not([data-stats-patched])");
cards.forEach((card) => {
card.setAttribute("data-stats-patched", "1");
const sha = card.dataset.sha256;
if (!sha || !_statsMap[sha]) return;
const stats = _statsMap[sha];
const container = document.createElement("div");
container.className = "lm-stat-badges";
const dlBadge = createStatBadge("download", stats.download_count, "Downloads");
const ratingBadge = createStatBadge("star",
stats.rating ? Number(stats.rating.toFixed(1)) : null, "Rating");
const thumbsBadge = createStatBadge("thumbs-up", stats.thumbs_up_count, "Likes");
[dlBadge, ratingBadge, thumbsBadge].forEach((b) => {
if (b) container.appendChild(b);
});
if (container.children.length > 0) {
// Insert inside .model-info, after .model-name
const modelInfo = card.querySelector(".model-info");
if (modelInfo) {
modelInfo.appendChild(container);
}
}
});
}
// ── Patch sort dropdown ────────────────────────────────────────
function patchSortDropdown() {
const select = document.getElementById("sortSelect");
if (!select || select.querySelector('[value="downloads:desc"]')) return;
const group = document.createElement("optgroup");
group.label = "CivitAI Stats";
const options = [
["downloads:desc", "Most downloaded"],
["downloads:asc", "Least downloaded"],
["rating:desc", "Highest rated"],
["rating:asc", "Lowest rated"],
["thumbsup:desc", "Most liked"],
["thumbsup:asc", "Least liked"],
];
options.forEach(([value, label]) => {
const opt = document.createElement("option");
opt.value = value;
opt.textContent = label;
group.appendChild(opt);
});
select.appendChild(group);
}
// ── Toolbar "Fetch Stats" button ───────────────────────────────
function addFetchStatsButton() {
const toolbar = document.querySelector(".action-buttons");
if (!toolbar || document.getElementById("fetchStatsBtn")) return;
const group = document.createElement("div");
group.className = "control-group";
group.innerHTML = `
<button id="fetchStatsBtn" data-action="fetch-stats"
title="Fetch CivitAI stats (downloads, ratings, likes)">
<i class="fas fa-chart-bar"></i> <span>Fetch Stats</span>
</button>
`;
// Insert before the bulk operations button
const bulkBtn = document.getElementById("bulkOperationsBtn");
if (bulkBtn && bulkBtn.closest(".control-group")) {
toolbar.insertBefore(group, bulkBtn.closest(".control-group"));
} else {
toolbar.appendChild(group);
}
group.querySelector("button").addEventListener("click", async () => {
const btn = document.getElementById("fetchStatsBtn");
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> <span>Fetching...</span>';
try {
const resp = await _origFetch("/api/lm-extra/fetch-stats", { method: "POST" });
const data = await resp.json();
if (data.success) {
btn.innerHTML = `<i class="fas fa-check"></i> <span>${data.updated} updated</span>`;
setTimeout(() => {
btn.innerHTML = '<i class="fas fa-chart-bar"></i> <span>Fetch Stats</span>';
btn.disabled = false;
}, 3000);
// Trigger page reload to show new stats
const sortSelect = document.getElementById("sortSelect");
if (sortSelect) {
sortSelect.dispatchEvent(new Event("change"));
}
} else {
throw new Error(data.error || "Unknown error");
}
} catch (err) {
btn.innerHTML = '<i class="fas fa-exclamation-triangle"></i> <span>Error</span>';
console.error("[LM-Stats] Fetch failed:", err);
setTimeout(() => {
btn.innerHTML = '<i class="fas fa-chart-bar"></i> <span>Fetch Stats</span>';
btn.disabled = false;
}, 3000);
}
});
}
// ── Observe DOM for card rendering ─────────────────────────────
function startObserver() {
injectStyles();
patchSortDropdown();
addFetchStatsButton();
patchCards();
const observer = new MutationObserver(() => {
patchCards();
patchSortDropdown();
addFetchStatsButton();
});
observer.observe(document.body, { childList: true, subtree: true });
}
// ── Init ───────────────────────────────────────────────────────
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", startObserver);
} else {
startObserver();
}
})();