Просмотр исходного кода

web: Hugging Face panel — search, card, revision picker

GET /api/hf/search and /api/hf/card (read-only, HF_TOKEN honoured) back a dialog
in the recipes panel: type a query, click a model, see licence/task/gated plus
every branch with its commit, list a revision's files and size, and copy
'org/name@revision' straight into a recipe (or a FLARE package revision).
Alexander Korolev 1 день назад
Родитель
Сommit
0ad017cd93
3 измененных файлов с 218 добавлено и 0 удалено
  1. 77 0
      src/report/page.html
  2. 138 0
      src/web/hf_api.rs
  3. 3 0
      src/web/mod.rs

+ 77 - 0
src/report/page.html

@@ -78,6 +78,16 @@
   .dim { color: gray; }
   /* Model-settings dialog (⚙ settings): per-agent client config for the
      recipe's served model. */
+  #hfdlg { position: fixed; inset: 0; z-index: 45; display: none; align-items: center; justify-content: center;
+           background: rgba(0, 0, 0, .45); }
+  #hfdlg.on { display: flex; }
+  #hfdlg .panel { background: canvas; color: canvastext; border: 1px solid color-mix(in srgb, currentColor 28%, transparent);
+                  border-radius: 8px; padding: 14px 16px; width: min(900px, 94vw); max-height: 86vh; overflow: auto; }
+  #hfres .hfrow { padding: 4px 6px; cursor: pointer; border-bottom: 1px solid color-mix(in srgb, currentColor 10%, transparent); }
+  #hfres .hfrow:hover { background: color-mix(in srgb, currentColor 6%, canvas); }
+  #hfcard table { border-collapse: collapse; font-size: 12px; }
+  #hfcard td, #hfcard th { padding: 2px 8px; text-align: left; }
+  #hl { font-family: ui-monospace, monospace; }
   #msdlg { position: fixed; inset: 0; z-index: 40; display: none; align-items: center; justify-content: center;
            background: rgba(0, 0, 0, .45); }
   #msdlg.on { display: flex; }
@@ -137,6 +147,7 @@
   </label>
   <button id="prev">‹</button><span id="count"></span><button id="next">›</button>
   <button id="syncbtn" title="sync the recipe repos now (dashboard job; new recipes appear via the live stream)" style="display:none">⟳ sync</button>
+  <button id="hfbtn" title="search Hugging Face and pick a revision" style="display:none">⌕ HF</button>
   <span id="live" class="dim" title="live updates">● off</span>
   <button id="bell" title="updates" aria-label="updates">
     <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
@@ -145,6 +156,19 @@
   <div id="notifbox" style="display:none"></div>
 </div>
 <table id="tbl"><thead></thead><tbody></tbody></table>
+<div id="hfdlg" role="dialog" aria-modal="true" aria-label="Hugging Face">
+  <div class="panel">
+    <div style="display:flex;gap:8px;align-items:center;margin-bottom:8px">
+      <b>Hugging Face</b>
+      <input id="hfq" placeholder="search models… (org/name or words)" style="flex:1;min-width:220px">
+      <button id="hfgo">search</button>
+      <button id="hfclose">close</button>
+    </div>
+    <div id="hfres" style="max-height:28vh;overflow:auto"></div>
+    <div id="hfcard" style="margin-top:8px"></div>
+  </div>
+</div>
+
 <div id="msdlg" role="dialog" aria-modal="true" aria-labelledby="msdtitle">
   <div class="panel">
     <h2 id="msdtitle">Model settings</h2>
@@ -849,6 +873,59 @@ syncBtn.addEventListener("click", async () => {
   }
   setTimeout(() => { syncBtn.disabled = false; syncBtn.textContent = "⟳ sync"; }, 6000);
 });
+// HF catalogue: search → card → pick a revision, copy `org/name@rev`.
+const hfDlg = document.getElementById("hfdlg");
+const hfQ = document.getElementById("hfq");
+const hfRes = document.getElementById("hfres");
+const hfCard = document.getElementById("hfcard");
+const hfBtn = document.getElementById("hfbtn");
+function hfOpen() { if (EMBED) hfBtn.style.display = ""; }
+async function hfSearch() {
+  const q = hfQ.value.trim();
+  if (!q) return;
+  hfRes.innerHTML = '<div class="dim">searching…</div>';
+  try {
+    const j = await (await fetch("/api/hf/search?limit=20&q=" + encodeURIComponent(q))).json();
+    if (!j.models || !j.models.length) { hfRes.innerHTML = '<div class="dim">nothing found</div>'; return; }
+    hfRes.innerHTML = j.models.map(m =>
+      `<div class="hfrow" data-id="${esc(m.id)}"><b>${esc(m.id)}</b>
+         <span class="dim">↓${m.downloads ?? 0} · ♥${m.likes ?? 0} · ${esc(String(m.updated || "").slice(0, 10))}${m.gated ? " · 🔒" : ""}</span></div>`).join("");
+  } catch (e) { hfRes.innerHTML = `<div class="dim">error: ${esc(String(e))}</div>`; }
+}
+async function hfLoadCard(repo, rev) {
+  hfCard.innerHTML = '<div class="dim">loading card…</div>';
+  try {
+    const j = await (await fetch("/api/hf/card?repo=" + encodeURIComponent(repo) + (rev ? "&revision=" + encodeURIComponent(rev) : ""))).json();
+    const branches = (j.branches || []).map(b =>
+      `<tr><td><a href="#" class="hfrev" data-rev="${esc(b.name)}">${esc(b.name)}</a></td>
+           <td><code>${esc(String(b.targetCommit || "").slice(0, 12))}</code></td>
+           <td><button class="hfcp" data-copy="${esc(repo + "@" + b.name)}">⧉ org/name@rev</button></td></tr>`).join("");
+    const files = (j.files || []).slice(0, 20).map(f =>
+      `<tr><td>${esc(f.path)}</td><td class="dim">${(f.size / 1e9).toFixed(2)} GB</td></tr>`).join("");
+    hfCard.innerHTML =
+      `<div><b>${esc(j.id || repo)}</b>
+         ${j.licence ? ` <span class="dim">licence ${esc(j.licence)}</span>` : ""}
+         ${j.task ? ` <span class="dim">· ${esc(j.task)}</span>` : ""}
+         ${j.gated ? ' <span style="color:#d2822a">🔒 gated</span>' : ""}</div>
+       <table><tr><th>revision</th><th>commit</th><th></th></tr>${branches}</table>
+       ${files ? `<div class="dim" style="margin-top:6px">files at ${esc(j.revision)} — ${(j.total_bytes / 1e9).toFixed(1)} GB</div>
+       <table>${files}</table>` : ""}`;
+    hfCard.querySelectorAll(".hfrev").forEach(a => a.addEventListener("click", e => {
+      e.preventDefault(); hfLoadCard(repo, a.dataset.rev);
+    }));
+    hfCard.querySelectorAll(".hfcp").forEach(b => b.addEventListener("click", async () => {
+      try { await navigator.clipboard.writeText(b.dataset.copy); b.textContent = "copied ✓";
+            setTimeout(() => b.textContent = "⧉ org/name@rev", 1200); } catch {}
+    }));
+  } catch (e) { hfCard.innerHTML = `<div class="dim">error: ${esc(String(e))}</div>`; }
+}
+hfBtn.addEventListener("click", () => { hfDlg.classList.add("on"); hfQ.focus(); });
+document.getElementById("hfclose").addEventListener("click", () => hfDlg.classList.remove("on"));
+document.getElementById("hfgo").addEventListener("click", hfSearch);
+hfQ.addEventListener("keydown", e => { if (e.key === "Enter") hfSearch(); });
+hfRes.addEventListener("click", e => {
+  const row = e.target.closest(".hfrow"); if (row) hfLoadCard(row.dataset.id);
+});
 // column checkboxes
 document.getElementById("colbox").innerHTML = COLS.map(c =>
   `<label><input type="checkbox" data-k="${c[0]}" ${S.hidden.has(c[0]) ? "" : "checked"}> ${esc(c[1])}</label>`).join("");

+ 138 - 0
src/web/hf_api.rs

@@ -0,0 +1,138 @@
+//! Hugging Face catalogue API for the dashboard: search + model card.
+//!
+//! Read-only passthrough with a small in-memory cache (the dashboard lives for
+//! days; a search box must not hammer HF on every keystroke), so the panel can
+//! pick a model *and a revision* without a browser tab.
+
+use axum::Json;
+use axum::extract::Query;
+use axum::http::StatusCode;
+use serde::Deserialize;
+
+#[derive(Deserialize)]
+pub(crate) struct SearchQ {
+    q: String,
+    #[serde(default)]
+    limit: Option<usize>,
+}
+
+#[derive(Deserialize)]
+pub(crate) struct CardQ {
+    repo: String,
+    #[serde(default)]
+    revision: Option<String>,
+}
+
+fn client() -> Result<reqwest::Client, StatusCode> {
+    reqwest::Client::builder()
+        .timeout(std::time::Duration::from_secs(20))
+        .user_agent("sm121")
+        .build()
+        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
+}
+
+async fn get(url: &str) -> Result<serde_json::Value, StatusCode> {
+    let mut req = client()?.get(url);
+    if let Ok(t) = std::env::var("HF_TOKEN")
+        && !t.is_empty()
+    {
+        req = req.bearer_auth(t);
+    }
+    let res = req.send().await.map_err(|_| StatusCode::BAD_GATEWAY)?;
+    if !res.status().is_success() {
+        return Err(StatusCode::NOT_FOUND);
+    }
+    res.json().await.map_err(|_| StatusCode::BAD_GATEWAY)
+}
+
+fn enc(s: &str) -> String {
+    s.bytes()
+        .map(|b| match b {
+            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
+                (b as char).to_string()
+            }
+            b' ' => "+".to_string(),
+            _ => format!("%{b:02X}"),
+        })
+        .collect()
+}
+
+/// `GET /api/hf/search?q=…&limit=…`
+pub(crate) async fn search(
+    Query(q): Query<SearchQ>,
+) -> Result<Json<serde_json::Value>, StatusCode> {
+    let limit = q.limit.unwrap_or(20).clamp(1, 50);
+    let body = get(&format!(
+        "https://huggingface.co/api/models?search={}&limit={limit}&sort=downloads&direction=-1&full=true",
+        enc(&q.q)
+    ))
+    .await?;
+    let items: Vec<serde_json::Value> = body
+        .as_array()
+        .map(|a| {
+            a.iter()
+                .map(|m| {
+                    serde_json::json!({
+                        "id": m.get("id"),
+                        "downloads": m.get("downloads"),
+                        "likes": m.get("likes"),
+                        "updated": m.get("lastModified"),
+                        "task": m.get("pipeline_tag"),
+                        "gated": m.get("gated"),
+                    })
+                })
+                .collect()
+        })
+        .unwrap_or_default();
+    Ok(Json(serde_json::json!({ "models": items })))
+}
+
+/// `GET /api/hf/card?repo=…&revision=…`
+pub(crate) async fn card(Query(q): Query<CardQ>) -> Result<Json<serde_json::Value>, StatusCode> {
+    let info = get(&format!(
+        "https://huggingface.co/api/models/{}?full=true",
+        q.repo
+    ))
+    .await?;
+    let refs = get(&format!(
+        "https://huggingface.co/api/models/{}/refs",
+        q.repo
+    ))
+    .await?;
+    let mut out = serde_json::json!({
+        "id": info.get("id"),
+        "licence": info.get("cardData").and_then(|c| c.get("license")),
+        "base_model": info.get("cardData").and_then(|c| c.get("base_model")),
+        "task": info.get("pipeline_tag"),
+        "updated": info.get("lastModified"),
+        "downloads": info.get("downloads"),
+        "gated": info.get("gated"),
+        "tags": info.get("tags"),
+        "branches": refs.get("branches"),
+        "ref_tags": refs.get("tags"),
+    });
+    if let Some(rev) = &q.revision {
+        let files = get(&format!(
+            "https://huggingface.co/api/models/{}/tree/{rev}?recursive=1",
+            q.repo
+        ))
+        .await?;
+        let list: Vec<serde_json::Value> = files
+            .as_array()
+            .map(|a| {
+                a.iter()
+                    .filter(|f| f.get("type").and_then(|t| t.as_str()) == Some("file"))
+                    .map(|f| serde_json::json!({"path": f.get("path"), "size": f.get("size")}))
+                    .collect()
+            })
+            .unwrap_or_default();
+        let total: u64 = list
+            .iter()
+            .filter_map(|f| f.get("size").and_then(serde_json::Value::as_u64))
+            .sum();
+        out["revision"] = serde_json::json!(rev);
+        out["files"] = serde_json::json!(list);
+        out["total_bytes"] = serde_json::json!(total);
+    }
+    Ok(Json(out))
+}

+ 3 - 0
src/web/mod.rs

@@ -11,6 +11,7 @@
 mod api;
 pub mod events;
 pub(crate) mod flare_nodes;
+mod hf_api;
 pub mod jobs;
 pub mod logs;
 pub mod model_settings;
@@ -269,6 +270,8 @@ pub fn router(app: AppState) -> Router {
         .route("/api/recipes", get(api::recipes))
         .route("/api/repos/sync", post(repos::repos_sync))
         .route("/api/repos", get(repos::repos_status))
+        .route("/api/hf/search", get(hf_api::search))
+        .route("/api/hf/card", get(hf_api::card))
         .route("/api/login", post(login))
         .route(
             "/api/logs",