Spaces:
Sleeping
Sleeping
Add /jobs page (CI-style UI) + /community v0 stub (#1)
Browse files- Add /jobs CI-style page + /community v0 stub (98cf995979cf72e3d8319f72dc0a0ecb58877698)
Co-authored-by: Arthur Zucker <ArthurZ@users.noreply.huggingface.co>
- COMMUNITY.md +159 -0
- Dockerfile +1 -1
- README.md +46 -0
- app.py +298 -4
- community.html +120 -0
- jobs.html +428 -0
COMMUNITY.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Slyfox — community roadmap
|
| 2 |
+
|
| 3 |
+
## Why this matters
|
| 4 |
+
|
| 5 |
+
Claude Code (and equivalents) generate a uniquely rich signal: a maintainer's
|
| 6 |
+
running internal monologue while they fix bugs, explain weird design choices,
|
| 7 |
+
and triage broken builds. Today, that signal evaporates the moment the user's
|
| 8 |
+
terminal closes. Two things change when maintainers can opt in to publishing
|
| 9 |
+
their traces:
|
| 10 |
+
|
| 11 |
+
1. **New contributors get a runway.** Someone joining the `transformers` repo
|
| 12 |
+
for the first time can see how Arthur thinks about a tokenizer fix — without
|
| 13 |
+
needing a 1:1 onboarding session.
|
| 14 |
+
2. **Open-source models get a training edge.** High-signal expert traces are
|
| 15 |
+
exactly the kind of data closed labs already farm internally. Putting them
|
| 16 |
+
in the open shifts the data moat: open-weight models can be fine-tuned on
|
| 17 |
+
the same kind of corpora that drive frontier-model behavior.
|
| 18 |
+
|
| 19 |
+
For both of those to work, slyfox needs to be community-centric, not a
|
| 20 |
+
private-dashboard tool. This doc sketches what that looks like.
|
| 21 |
+
|
| 22 |
+
## North-star user stories
|
| 23 |
+
|
| 24 |
+
- **Maintainer Maya** runs `hf upload-traces --share community` after a long
|
| 25 |
+
session on `vllm-project/vllm`. Her bundle goes to a private bucket *and* an
|
| 26 |
+
opt-in public catalog with a model-fit summary attached.
|
| 27 |
+
- **Contributor Carlos** browses `huggingface/transformers` on the slyfox
|
| 28 |
+
Space, sees that "Gemma 2 9B" tracks Arthur's emotional cadence the closest
|
| 29 |
+
on long sessions, picks it for his fork's CI.
|
| 30 |
+
- **Researcher Riya** fine-tunes a 7B model on the public catalog's
|
| 31 |
+
exported-for-training dataset, posts results back to a leaderboard slot.
|
| 32 |
+
|
| 33 |
+
## Consent + visibility model
|
| 34 |
+
|
| 35 |
+
Nothing leaves a maintainer's machine without an explicit flag. The
|
| 36 |
+
defaults are strict; the opt-ins are loud.
|
| 37 |
+
|
| 38 |
+
| State | Default | What it means |
|
| 39 |
+
| ------------ | ------- | -------------------------------------------------------------------------- |
|
| 40 |
+
| `private` | ✅ | Bucket is theirs only. (Current behavior of `hf upload-traces`.) |
|
| 41 |
+
| `org` | | Bucket is shared within a chosen HF org. Discoverable by org members only. |
|
| 42 |
+
| `community` | | Bundle manifest is published to `HF-slyfox/community-index`; anyone can browse. The trace bucket itself stays per-maintainer-controlled (public / private / read-token). |
|
| 43 |
+
| `training` | | Maintainer also tags the bundle with a CC-BY-style flag, signalling that the published traces may be redistributed in training corpora. |
|
| 44 |
+
|
| 45 |
+
Each escalation is a separate flag (`--share`, `--share-org`, `--publish`,
|
| 46 |
+
`--license cc-by-4.0`) — you never get bumped up a tier silently.
|
| 47 |
+
|
| 48 |
+
## Phased rollout
|
| 49 |
+
|
| 50 |
+
### Phase 1 — Catalog (this PR + a follow-up)
|
| 51 |
+
- `/community` page on the Space lists opt-in published runs from
|
| 52 |
+
`hf://buckets/HF-slyfox/community-index/runs/*.json`.
|
| 53 |
+
- `hf upload-traces` gains `--share community` that writes a minimal
|
| 54 |
+
manifest entry: `{run_id, owner, repo, bucket, summary, created_at}`.
|
| 55 |
+
- API: `/api/community/runs` — already stubbed in this PR.
|
| 56 |
+
|
| 57 |
+
### Phase 2 — Browse + remix
|
| 58 |
+
- Each catalog entry has a "Remix" button that resubmits the analysis with the
|
| 59 |
+
current user's chosen model (calls `/api/analyses` with the source/prefix from
|
| 60 |
+
the manifest).
|
| 61 |
+
- Per-run page with the EmotionScope orb animation timed to the session's
|
| 62 |
+
timestamps.
|
| 63 |
+
- Comments / annotations via HF Discussions API on the underlying bucket / dataset.
|
| 64 |
+
|
| 65 |
+
### Phase 3 — Persona leaderboard
|
| 66 |
+
- For each public bundle, the slyfox Space computes a "persona vector"
|
| 67 |
+
(average emotion profile, talking-style features, code-language mix) and a
|
| 68 |
+
per-model fit score across the supported model catalog.
|
| 69 |
+
- `/community/leaderboard?repo=huggingface/transformers` shows which models
|
| 70 |
+
best match each maintainer's persona on that repo.
|
| 71 |
+
|
| 72 |
+
### Phase 4 — Training-dataset export
|
| 73 |
+
- Public bundles tagged `--license cc-by-4.0` can be aggregated into a
|
| 74 |
+
redistributable dataset (`HF-slyfox/training-set`) suitable for SFT/DPO.
|
| 75 |
+
- The exported dataset is *re-scrubbed* (paths, secrets, emails) on top of the
|
| 76 |
+
uploader-side filter, with a stricter regex set — defense in depth.
|
| 77 |
+
- Periodic releases versioned as `HF-slyfox/training-set:v0.1`, `:v0.2`, etc.
|
| 78 |
+
|
| 79 |
+
### Phase 5 — Org-level governance
|
| 80 |
+
- Org admins can set `slyfox.community.policy = {allow|deny|approve}` on their
|
| 81 |
+
HF org; uploads that target a community share check the policy before
|
| 82 |
+
publishing.
|
| 83 |
+
- Per-repo opt-out (e.g. a private repo never publishes).
|
| 84 |
+
- "Trace of the week" highlighted on the slyfox landing page.
|
| 85 |
+
|
| 86 |
+
## Data shapes
|
| 87 |
+
|
| 88 |
+
### Community manifest (one JSON file per published run)
|
| 89 |
+
|
| 90 |
+
```json
|
| 91 |
+
{
|
| 92 |
+
"schema_version": 1,
|
| 93 |
+
"kind": "analysis-run",
|
| 94 |
+
"run_id": "20260522-184201-a1b2c3",
|
| 95 |
+
"owner": "ArthurZ",
|
| 96 |
+
"repo": "huggingface/transformers",
|
| 97 |
+
"model_id": "google/gemma-2-9b-it",
|
| 98 |
+
"vectors_ref": "hf://buckets/HF-slyfox/emotion-vectors/google/gemma-2-9b-it/",
|
| 99 |
+
"source_bucket": "ArthurZ/transformers-traces",
|
| 100 |
+
"trace_count": 5,
|
| 101 |
+
"license": "cc-by-4.0",
|
| 102 |
+
"summary": {
|
| 103 |
+
"valence_mean": 0.31,
|
| 104 |
+
"arousal_mean": 0.62,
|
| 105 |
+
"dominant_emotions": ["focused", "curious", "amused"]
|
| 106 |
+
},
|
| 107 |
+
"consent": {
|
| 108 |
+
"scope": "community",
|
| 109 |
+
"training_use": true,
|
| 110 |
+
"issued_at": "2026-05-22T18:42:01Z"
|
| 111 |
+
},
|
| 112 |
+
"links": {
|
| 113 |
+
"results_prefix": "hf://buckets/ArthurZ/transformers-traces/trace-analysis/20260522-184201-a1b2c3/",
|
| 114 |
+
"discussions": "https://huggingface.co/datasets/ArthurZ/transformers-traces/discussions"
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
### Index layout
|
| 120 |
+
|
| 121 |
+
```
|
| 122 |
+
HF-slyfox/community-index/
|
| 123 |
+
runs/
|
| 124 |
+
20260522-184201-a1b2c3.json # per-run manifests, append-only
|
| 125 |
+
orgs/
|
| 126 |
+
huggingface.json # one-file-per-org summary
|
| 127 |
+
vllm-project.json
|
| 128 |
+
leaderboard/
|
| 129 |
+
huggingface__transformers.json # cached persona-vs-model scores
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
## What's already in place vs. what's still on paper
|
| 133 |
+
|
| 134 |
+
| Piece | State |
|
| 135 |
+
| ---------------------------------- | -------------- |
|
| 136 |
+
| Trace upload with filtering | ✅ shipped (`hf upload-traces`) |
|
| 137 |
+
| `hf jobs uv` integration | ✅ this PR |
|
| 138 |
+
| `/api/community/runs` endpoint | ✅ stub in this PR |
|
| 139 |
+
| `/community` page | ✅ stub in this PR |
|
| 140 |
+
| `--share community` upload flag | 🚧 next |
|
| 141 |
+
| Catalog write-side (manifest writer) | 🚧 next |
|
| 142 |
+
| Remix UI + per-run page | 🔜 phase 2 |
|
| 143 |
+
| Persona leaderboard | 🔜 phase 3 |
|
| 144 |
+
| Training-dataset export | 🔜 phase 4 |
|
| 145 |
+
| Org-level policy / opt-out | 🔜 phase 5 |
|
| 146 |
+
|
| 147 |
+
## Open questions
|
| 148 |
+
|
| 149 |
+
- **Anonymization**: is a `username` ever fine to surface, or do we go fully
|
| 150 |
+
pseudonymous (`maintainer-1234`) until a user explicitly publishes under
|
| 151 |
+
their handle? Probably the latter as default with an opt-in toggle.
|
| 152 |
+
- **Quality bar**: should the community catalog filter junk runs (e.g. 1-turn
|
| 153 |
+
sessions)? Likely yes via a minimum-turns threshold.
|
| 154 |
+
- **Reciprocity**: should publishing be a prerequisite for accessing premium
|
| 155 |
+
analysis features (GPU time)? Soft incentive vs. paywall — needs a
|
| 156 |
+
discussion with the org.
|
| 157 |
+
- **Redaction asymmetry**: should the community catalog re-run the filter
|
| 158 |
+
with a *stricter* regex set than the per-user uploader, on the theory that
|
| 159 |
+
publishers may have less context to judge what they leak?
|
Dockerfile
CHANGED
|
@@ -9,7 +9,7 @@ WORKDIR /app
|
|
| 9 |
COPY requirements.txt .
|
| 10 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 11 |
|
| 12 |
-
COPY app.py index.html ./
|
| 13 |
|
| 14 |
# HF Spaces require listening on $PORT (default 7860).
|
| 15 |
ENV PORT=7860
|
|
|
|
| 9 |
COPY requirements.txt .
|
| 10 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 11 |
|
| 12 |
+
COPY app.py index.html jobs.html community.html ./
|
| 13 |
|
| 14 |
# HF Spaces require listening on $PORT (default 7860).
|
| 15 |
ENV PORT=7860
|
README.md
CHANGED
|
@@ -67,3 +67,49 @@ HF_TOKEN=hf_… uvicorn app:app --reload --port 7860
|
|
| 67 |
```
|
| 68 |
|
| 69 |
Then visit `http://localhost:7860/?api=http://localhost:8000/v1`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
```
|
| 68 |
|
| 69 |
Then visit `http://localhost:7860/?api=http://localhost:8000/v1`.
|
| 70 |
+
|
| 71 |
+
## Jobs page
|
| 72 |
+
|
| 73 |
+
A second route, `/jobs`, exposes two operations that submit Hugging Face Jobs
|
| 74 |
+
on behalf of the user. Both run via `HfApi.run_uv_job` and report CI-style
|
| 75 |
+
status back into the UI.
|
| 76 |
+
|
| 77 |
+
| Operation | Submits | Output |
|
| 78 |
+
| --- | --- | --- |
|
| 79 |
+
| **Request a new model** | `jobs_scripts/extract_vectors.py` | `hf://buckets/HF-slyfox/emotion-vectors/<model_id>/` |
|
| 80 |
+
| **Analyze traces** | `jobs_scripts/analyze_traces.py` | `hf://buckets/<source>/trace-analysis/<run_id>/` |
|
| 81 |
+
|
| 82 |
+
API surface (additive — none of the existing `/api/chats/*` routes change):
|
| 83 |
+
|
| 84 |
+
```
|
| 85 |
+
POST /api/models/extract { model_id, flavor? }
|
| 86 |
+
POST /api/analyses { source, prefix?, n_traces, model_id, flavor? }
|
| 87 |
+
GET /api/jobs list slyfox-submitted jobs with live status
|
| 88 |
+
GET /api/jobs/{job_id} one job's record + live status
|
| 89 |
+
GET /api/jobs/{job_id}/logs tail of the job's stdout/stderr
|
| 90 |
+
POST /api/jobs/{job_id}/cancel cancel a running/queued job
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
Bucket layout extension:
|
| 94 |
+
|
| 95 |
+
```
|
| 96 |
+
SLYFOX_BUCKET/
|
| 97 |
+
...existing chat layout...
|
| 98 |
+
jobs/<job_id>.json # sidecar record for each submitted job
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
Extra env vars:
|
| 102 |
+
|
| 103 |
+
| Variable | Default | Description |
|
| 104 |
+
| --- | --- | --- |
|
| 105 |
+
| `SLYFOX_JOBS_REF` | `main` | Git ref of `huggingface/slyfox` to pull UV scripts from. |
|
| 106 |
+
| `SLYFOX_VECTORS_BUCKET` | `HF-slyfox/emotion-vectors` | Where extracted vectors land. |
|
| 107 |
+
| `SLYFOX_COMMUNITY_BUCKET` | `HF-slyfox/community-index` | Public index of opt-in shared runs. |
|
| 108 |
+
|
| 109 |
+
## Community (v0)
|
| 110 |
+
|
| 111 |
+
A third route, `/community`, is a placeholder for the upcoming public catalog
|
| 112 |
+
of opt-in shared analysis runs. The `/api/community/runs` endpoint already
|
| 113 |
+
reads from `SLYFOX_COMMUNITY_BUCKET`; the publish-side flow (`hf upload-traces
|
| 114 |
+
--share community`) is on the roadmap. See [`COMMUNITY.md`](./COMMUNITY.md)
|
| 115 |
+
for the full design.
|
app.py
CHANGED
|
@@ -4,6 +4,7 @@ Layout in the bucket (one file per chat):
|
|
| 4 |
|
| 5 |
users/<user_id>/chats/<chat_id>.json
|
| 6 |
users/<user_id>/index.json # ordered list of chat ids + titles + topics
|
|
|
|
| 7 |
|
| 8 |
`<user_id>` is currently an anonymous UUID minted in the browser (stored in
|
| 9 |
localStorage) and sent as `X-User-Id`. This is intentionally minimal — swap for
|
|
@@ -17,6 +18,7 @@ import json
|
|
| 17 |
import logging
|
| 18 |
import os
|
| 19 |
import re
|
|
|
|
| 20 |
from pathlib import Path
|
| 21 |
from typing import Any
|
| 22 |
|
|
@@ -33,11 +35,20 @@ log = logging.getLogger("slyfox")
|
|
| 33 |
BUCKET_ID = os.getenv("SLYFOX_BUCKET", "HF-slyfox/slyfox-chats")
|
| 34 |
HF_TOKEN = os.getenv("HF_TOKEN") # Spaces inject this automatically.
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
api = HfApi(token=HF_TOKEN)
|
| 37 |
|
| 38 |
ROOT = Path(__file__).parent
|
| 39 |
USER_RE = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
|
| 40 |
CHAT_RE = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
|
|
|
|
|
|
|
| 41 |
|
| 42 |
|
| 43 |
def _user_prefix(user_id: str) -> str:
|
|
@@ -66,8 +77,6 @@ def _read_json(remote_path: str) -> Any | None:
|
|
| 66 |
log.warning("metadata failed for %s: %s", remote_path, e)
|
| 67 |
return None
|
| 68 |
|
| 69 |
-
# `download_bucket_files` writes to disk; pull bytes via the file handle utility.
|
| 70 |
-
# Easiest cross-version approach: download to a temp path and read it back.
|
| 71 |
import tempfile
|
| 72 |
|
| 73 |
with tempfile.TemporaryDirectory() as tmp:
|
|
@@ -87,6 +96,24 @@ def _write_json(remote_path: str, payload: Any) -> None:
|
|
| 87 |
api.batch_bucket_files(BUCKET_ID, add=[(data, remote_path)])
|
| 88 |
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
def _delete(remote_path: str) -> None:
|
| 91 |
try:
|
| 92 |
api.batch_bucket_files(BUCKET_ID, delete=[remote_path])
|
|
@@ -94,9 +121,18 @@ def _delete(remote_path: str) -> None:
|
|
| 94 |
log.warning("delete failed for %s: %s", remote_path, e)
|
| 95 |
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
app = FastAPI(title="Slyfox")
|
| 98 |
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
@app.get("/api/chats")
|
| 101 |
def list_chats(x_user_id: str = Header(...)) -> JSONResponse:
|
| 102 |
"""Return the per-user index: [{id, title, topic, updated_at}, ...]."""
|
|
@@ -115,13 +151,11 @@ def get_chat(chat_id: str, x_user_id: str = Header(...)) -> JSONResponse:
|
|
| 115 |
@app.put("/api/chats/{chat_id}")
|
| 116 |
async def put_chat(chat_id: str, request: Request, x_user_id: str = Header(...)) -> JSONResponse:
|
| 117 |
body = await request.json()
|
| 118 |
-
# Expected shape: {id, title, topic, history: [...], updated_at}
|
| 119 |
if body.get("id") != chat_id:
|
| 120 |
raise HTTPException(400, "chat id mismatch")
|
| 121 |
|
| 122 |
_write_json(_chat_path(x_user_id, chat_id), body)
|
| 123 |
|
| 124 |
-
# Update the per-user index (one row per chat, newest first).
|
| 125 |
idx = _read_json(_index_path(x_user_id)) or []
|
| 126 |
idx = [e for e in idx if e.get("id") != chat_id]
|
| 127 |
idx.insert(
|
|
@@ -151,7 +185,267 @@ def healthz() -> dict:
|
|
| 151 |
return {"ok": True, "bucket": BUCKET_ID}
|
| 152 |
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
# Static frontend — mounted last so /api/* routes win.
|
|
|
|
|
|
|
|
|
|
| 155 |
@app.get("/")
|
| 156 |
def index() -> FileResponse:
|
| 157 |
return FileResponse(ROOT / "index.html")
|
|
|
|
| 4 |
|
| 5 |
users/<user_id>/chats/<chat_id>.json
|
| 6 |
users/<user_id>/index.json # ordered list of chat ids + titles + topics
|
| 7 |
+
jobs/<job_id>.json # slyfox-submitted hf jobs (extract / analyze)
|
| 8 |
|
| 9 |
`<user_id>` is currently an anonymous UUID minted in the browser (stored in
|
| 10 |
localStorage) and sent as `X-User-Id`. This is intentionally minimal — swap for
|
|
|
|
| 18 |
import logging
|
| 19 |
import os
|
| 20 |
import re
|
| 21 |
+
from datetime import datetime, timezone
|
| 22 |
from pathlib import Path
|
| 23 |
from typing import Any
|
| 24 |
|
|
|
|
| 35 |
BUCKET_ID = os.getenv("SLYFOX_BUCKET", "HF-slyfox/slyfox-chats")
|
| 36 |
HF_TOKEN = os.getenv("HF_TOKEN") # Spaces inject this automatically.
|
| 37 |
|
| 38 |
+
# Job submission config — URLs of UV scripts that run on hf jobs infra.
|
| 39 |
+
SLYFOX_JOBS_REF = os.getenv("SLYFOX_JOBS_REF", "arthur-work") # branch / tag of the slyfox repo (`main` once promoted)
|
| 40 |
+
JOBS_SCRIPT_BASE = f"https://raw.githubusercontent.com/huggingface/slyfox/{SLYFOX_JOBS_REF}/jobs_scripts"
|
| 41 |
+
EXTRACT_SCRIPT_URL = f"{JOBS_SCRIPT_BASE}/extract_vectors.py"
|
| 42 |
+
ANALYZE_SCRIPT_URL = f"{JOBS_SCRIPT_BASE}/analyze_traces.py"
|
| 43 |
+
VECTORS_BUCKET = os.getenv("SLYFOX_VECTORS_BUCKET", "HF-slyfox/emotion-vectors")
|
| 44 |
+
|
| 45 |
api = HfApi(token=HF_TOKEN)
|
| 46 |
|
| 47 |
ROOT = Path(__file__).parent
|
| 48 |
USER_RE = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
|
| 49 |
CHAT_RE = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
|
| 50 |
+
MODEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._\-]*/[A-Za-z0-9][A-Za-z0-9._\-]*$")
|
| 51 |
+
BUCKET_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._\-]*/[A-Za-z0-9][A-Za-z0-9._\-]*$")
|
| 52 |
|
| 53 |
|
| 54 |
def _user_prefix(user_id: str) -> str:
|
|
|
|
| 77 |
log.warning("metadata failed for %s: %s", remote_path, e)
|
| 78 |
return None
|
| 79 |
|
|
|
|
|
|
|
| 80 |
import tempfile
|
| 81 |
|
| 82 |
with tempfile.TemporaryDirectory() as tmp:
|
|
|
|
| 96 |
api.batch_bucket_files(BUCKET_ID, add=[(data, remote_path)])
|
| 97 |
|
| 98 |
|
| 99 |
+
def _list_json(prefix: str) -> list[dict]:
|
| 100 |
+
"""List JSON objects directly under `prefix/` in the bucket."""
|
| 101 |
+
try:
|
| 102 |
+
files = list(api.list_bucket_files(BUCKET_ID, path=prefix))
|
| 103 |
+
except Exception as e: # noqa: BLE001
|
| 104 |
+
log.warning("list failed for %s: %s", prefix, e)
|
| 105 |
+
return []
|
| 106 |
+
out: list[dict] = []
|
| 107 |
+
for f in files:
|
| 108 |
+
path = getattr(f, "path", None) or getattr(f, "rfilename", None) or str(f)
|
| 109 |
+
if not path.endswith(".json"):
|
| 110 |
+
continue
|
| 111 |
+
data = _read_json(path)
|
| 112 |
+
if isinstance(data, dict):
|
| 113 |
+
out.append(data)
|
| 114 |
+
return out
|
| 115 |
+
|
| 116 |
+
|
| 117 |
def _delete(remote_path: str) -> None:
|
| 118 |
try:
|
| 119 |
api.batch_bucket_files(BUCKET_ID, delete=[remote_path])
|
|
|
|
| 121 |
log.warning("delete failed for %s: %s", remote_path, e)
|
| 122 |
|
| 123 |
|
| 124 |
+
def _now_iso() -> str:
|
| 125 |
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
| 126 |
+
|
| 127 |
+
|
| 128 |
app = FastAPI(title="Slyfox")
|
| 129 |
|
| 130 |
|
| 131 |
+
# =============================================================================
|
| 132 |
+
# Existing chat persistence API (unchanged)
|
| 133 |
+
# =============================================================================
|
| 134 |
+
|
| 135 |
+
|
| 136 |
@app.get("/api/chats")
|
| 137 |
def list_chats(x_user_id: str = Header(...)) -> JSONResponse:
|
| 138 |
"""Return the per-user index: [{id, title, topic, updated_at}, ...]."""
|
|
|
|
| 151 |
@app.put("/api/chats/{chat_id}")
|
| 152 |
async def put_chat(chat_id: str, request: Request, x_user_id: str = Header(...)) -> JSONResponse:
|
| 153 |
body = await request.json()
|
|
|
|
| 154 |
if body.get("id") != chat_id:
|
| 155 |
raise HTTPException(400, "chat id mismatch")
|
| 156 |
|
| 157 |
_write_json(_chat_path(x_user_id, chat_id), body)
|
| 158 |
|
|
|
|
| 159 |
idx = _read_json(_index_path(x_user_id)) or []
|
| 160 |
idx = [e for e in idx if e.get("id") != chat_id]
|
| 161 |
idx.insert(
|
|
|
|
| 185 |
return {"ok": True, "bucket": BUCKET_ID}
|
| 186 |
|
| 187 |
|
| 188 |
+
# =============================================================================
|
| 189 |
+
# Jobs API — emotion-vector extraction + trace analysis via `hf jobs`
|
| 190 |
+
# =============================================================================
|
| 191 |
+
#
|
| 192 |
+
# Two job kinds, both submitted via `HfApi.run_uv_job`:
|
| 193 |
+
#
|
| 194 |
+
# extract : take a model_id, run EmotionScope's vector extraction, write
|
| 195 |
+
# the 20 emotion direction vectors to a bucket under
|
| 196 |
+
# <VECTORS_BUCKET>/<model_id>/
|
| 197 |
+
#
|
| 198 |
+
# analyze : take a traces source (bucket+prefix) and a model_id, replay
|
| 199 |
+
# sessions through the model, project each turn's residual stream
|
| 200 |
+
# onto the emotion vectors, write per-trace parquet results under
|
| 201 |
+
# <source>/trace-analysis/<run_id>/
|
| 202 |
+
#
|
| 203 |
+
# A small sidecar record is written to the chats bucket under `jobs/<job_id>.json`
|
| 204 |
+
# so the Space can list its own jobs. Live status / logs are pulled directly
|
| 205 |
+
# from the HF jobs API on demand.
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _job_record(job_id: str, *, kind: str, params: dict, requester: str) -> dict:
|
| 209 |
+
return {
|
| 210 |
+
"job_id": job_id,
|
| 211 |
+
"kind": kind,
|
| 212 |
+
"params": params,
|
| 213 |
+
"requester": requester,
|
| 214 |
+
"submitted_at": _now_iso(),
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _hf_job_status(job_id: str) -> dict:
|
| 219 |
+
"""Live status snapshot for one job. Falls back gracefully on errors."""
|
| 220 |
+
try:
|
| 221 |
+
info = api.inspect_job(job_id)
|
| 222 |
+
except Exception as e: # noqa: BLE001
|
| 223 |
+
return {"job_id": job_id, "status": "unknown", "error": str(e)}
|
| 224 |
+
started = getattr(info, "started_at", None)
|
| 225 |
+
ended = getattr(info, "ended_at", None)
|
| 226 |
+
elapsed = None
|
| 227 |
+
if started:
|
| 228 |
+
end = ended or datetime.now(timezone.utc)
|
| 229 |
+
try:
|
| 230 |
+
elapsed = int((end - started).total_seconds())
|
| 231 |
+
except Exception: # noqa: BLE001
|
| 232 |
+
elapsed = None
|
| 233 |
+
return {
|
| 234 |
+
"job_id": job_id,
|
| 235 |
+
"status": getattr(info, "status", None) or getattr(info, "stage", "unknown"),
|
| 236 |
+
"flavor": getattr(info, "flavor", None),
|
| 237 |
+
"started_at": started.isoformat() if hasattr(started, "isoformat") else started,
|
| 238 |
+
"ended_at": ended.isoformat() if hasattr(ended, "isoformat") else ended,
|
| 239 |
+
"elapsed_s": elapsed,
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
@app.post("/api/models/extract")
|
| 244 |
+
async def request_model_extract(request: Request, x_user_id: str = Header(...)) -> JSONResponse:
|
| 245 |
+
"""Submit an emotion-vector extraction job for a HuggingFace model."""
|
| 246 |
+
_user_prefix(x_user_id)
|
| 247 |
+
body = await request.json()
|
| 248 |
+
model_id = (body.get("model_id") or "").strip()
|
| 249 |
+
flavor = body.get("flavor") or "a10g-large"
|
| 250 |
+
if not MODEL_RE.match(model_id):
|
| 251 |
+
raise HTTPException(400, "model_id must look like 'org/name'")
|
| 252 |
+
|
| 253 |
+
output = f"hf://buckets/{VECTORS_BUCKET}/{model_id}/"
|
| 254 |
+
job = api.run_uv_job(
|
| 255 |
+
script=EXTRACT_SCRIPT_URL,
|
| 256 |
+
script_args=["--model", model_id, "--output", output],
|
| 257 |
+
dependencies=[
|
| 258 |
+
"transformers>=4.45",
|
| 259 |
+
"torch",
|
| 260 |
+
"huggingface_hub>=1.7",
|
| 261 |
+
"scikit-learn",
|
| 262 |
+
"safetensors",
|
| 263 |
+
"numpy",
|
| 264 |
+
],
|
| 265 |
+
flavor=flavor,
|
| 266 |
+
timeout="45m",
|
| 267 |
+
labels={
|
| 268 |
+
"slyfox.io/type": "extract",
|
| 269 |
+
"slyfox.io/model": model_id,
|
| 270 |
+
"slyfox.io/requester": x_user_id,
|
| 271 |
+
},
|
| 272 |
+
)
|
| 273 |
+
record = _job_record(
|
| 274 |
+
job.id,
|
| 275 |
+
kind="extract",
|
| 276 |
+
params={"model_id": model_id, "flavor": flavor, "output": output},
|
| 277 |
+
requester=x_user_id,
|
| 278 |
+
)
|
| 279 |
+
_write_json(f"jobs/{job.id}.json", record)
|
| 280 |
+
return JSONResponse(record)
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
@app.post("/api/analyses")
|
| 284 |
+
async def request_analysis(request: Request, x_user_id: str = Header(...)) -> JSONResponse:
|
| 285 |
+
"""Submit a trace-analysis job: replay sessions through a model and emit emotion trajectories."""
|
| 286 |
+
_user_prefix(x_user_id)
|
| 287 |
+
body = await request.json()
|
| 288 |
+
source = (body.get("source") or "").strip() # bucket id, e.g. "HF-slyfox/traces"
|
| 289 |
+
prefix = (body.get("prefix") or "").strip().strip("/")
|
| 290 |
+
n_traces = int(body.get("n_traces") or 5)
|
| 291 |
+
model_id = (body.get("model_id") or "").strip()
|
| 292 |
+
flavor = body.get("flavor") or "a10g-large"
|
| 293 |
+
|
| 294 |
+
if not BUCKET_RE.match(source):
|
| 295 |
+
raise HTTPException(400, "source must look like 'namespace/bucket'")
|
| 296 |
+
if not MODEL_RE.match(model_id):
|
| 297 |
+
raise HTTPException(400, "model_id must look like 'org/name'")
|
| 298 |
+
if n_traces < 1 or n_traces > 100:
|
| 299 |
+
raise HTTPException(400, "n_traces must be 1..100")
|
| 300 |
+
|
| 301 |
+
source_url = f"hf://buckets/{source}/{prefix}/" if prefix else f"hf://buckets/{source}/"
|
| 302 |
+
output_url = f"hf://buckets/{source}/trace-analysis/"
|
| 303 |
+
vectors_url = f"hf://buckets/{VECTORS_BUCKET}/{model_id}/"
|
| 304 |
+
job = api.run_uv_job(
|
| 305 |
+
script=ANALYZE_SCRIPT_URL,
|
| 306 |
+
script_args=[
|
| 307 |
+
"--source", source_url,
|
| 308 |
+
"--model", model_id,
|
| 309 |
+
"--vectors", vectors_url,
|
| 310 |
+
"--n-traces", str(n_traces),
|
| 311 |
+
"--output", output_url,
|
| 312 |
+
],
|
| 313 |
+
dependencies=[
|
| 314 |
+
"transformers>=4.45",
|
| 315 |
+
"torch",
|
| 316 |
+
"huggingface_hub>=1.7",
|
| 317 |
+
"safetensors",
|
| 318 |
+
"numpy",
|
| 319 |
+
"pyarrow",
|
| 320 |
+
],
|
| 321 |
+
flavor=flavor,
|
| 322 |
+
timeout="2h",
|
| 323 |
+
labels={
|
| 324 |
+
"slyfox.io/type": "analyze",
|
| 325 |
+
"slyfox.io/model": model_id,
|
| 326 |
+
"slyfox.io/source": source,
|
| 327 |
+
"slyfox.io/requester": x_user_id,
|
| 328 |
+
},
|
| 329 |
+
)
|
| 330 |
+
record = _job_record(
|
| 331 |
+
job.id,
|
| 332 |
+
kind="analyze",
|
| 333 |
+
params={
|
| 334 |
+
"source": source,
|
| 335 |
+
"prefix": prefix,
|
| 336 |
+
"n_traces": n_traces,
|
| 337 |
+
"model_id": model_id,
|
| 338 |
+
"flavor": flavor,
|
| 339 |
+
"output": output_url,
|
| 340 |
+
"vectors": vectors_url,
|
| 341 |
+
},
|
| 342 |
+
requester=x_user_id,
|
| 343 |
+
)
|
| 344 |
+
_write_json(f"jobs/{job.id}.json", record)
|
| 345 |
+
return JSONResponse(record)
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
@app.get("/api/jobs")
|
| 349 |
+
def list_slyfox_jobs() -> JSONResponse:
|
| 350 |
+
"""List slyfox-submitted jobs (newest first) with live status merged in."""
|
| 351 |
+
records = _list_json("jobs")
|
| 352 |
+
records.sort(key=lambda r: r.get("submitted_at", ""), reverse=True)
|
| 353 |
+
out: list[dict] = []
|
| 354 |
+
for r in records:
|
| 355 |
+
out.append({**r, "live": _hf_job_status(r["job_id"])})
|
| 356 |
+
return JSONResponse(out)
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
@app.get("/api/jobs/{job_id}")
|
| 360 |
+
def get_job(job_id: str) -> JSONResponse:
|
| 361 |
+
record = _read_json(f"jobs/{job_id}.json") or {"job_id": job_id}
|
| 362 |
+
return JSONResponse({**record, "live": _hf_job_status(job_id)})
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
@app.get("/api/jobs/{job_id}/logs")
|
| 366 |
+
def get_job_logs(job_id: str, tail: int = 200) -> JSONResponse:
|
| 367 |
+
try:
|
| 368 |
+
chunks = list(api.fetch_job_logs(job_id))
|
| 369 |
+
except Exception as e: # noqa: BLE001
|
| 370 |
+
raise HTTPException(404, f"logs unavailable: {e}")
|
| 371 |
+
text = "".join(chunks)
|
| 372 |
+
lines = text.splitlines()
|
| 373 |
+
if tail and tail > 0:
|
| 374 |
+
lines = lines[-tail:]
|
| 375 |
+
return JSONResponse({"job_id": job_id, "lines": lines})
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
@app.post("/api/jobs/{job_id}/cancel")
|
| 379 |
+
def cancel_slyfox_job(job_id: str, x_user_id: str = Header(...)) -> JSONResponse:
|
| 380 |
+
_user_prefix(x_user_id)
|
| 381 |
+
try:
|
| 382 |
+
api.cancel_job(job_id)
|
| 383 |
+
except Exception as e: # noqa: BLE001
|
| 384 |
+
raise HTTPException(404, f"cancel failed: {e}")
|
| 385 |
+
return JSONResponse({"ok": True, "job_id": job_id})
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
@app.get("/jobs", include_in_schema=False)
|
| 389 |
+
def jobs_page() -> FileResponse:
|
| 390 |
+
return FileResponse(ROOT / "jobs.html")
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
# =============================================================================
|
| 394 |
+
# Community API — placeholder surface for org-level / public trace sharing.
|
| 395 |
+
# =============================================================================
|
| 396 |
+
#
|
| 397 |
+
# v0 stubs reserve the URL shape so future PRs can land without renaming.
|
| 398 |
+
# The intent (see COMMUNITY.md) is that orgs and users *opt in* to publishing
|
| 399 |
+
# their trace bundles + analysis runs to a public index, which the Space
|
| 400 |
+
# surfaces here so anyone can browse other maintainers' personas, remix
|
| 401 |
+
# analyses with their own model picks, and (eventually) export
|
| 402 |
+
# fine-tuning-friendly datasets from the published traces.
|
| 403 |
+
|
| 404 |
+
COMMUNITY_INDEX_BUCKET = os.getenv("SLYFOX_COMMUNITY_BUCKET", "HF-slyfox/community-index")
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
@app.get("/api/community/runs")
|
| 408 |
+
def community_runs() -> JSONResponse:
|
| 409 |
+
"""Return the public list of opt-in published analysis runs.
|
| 410 |
+
|
| 411 |
+
v0: returns whatever has been written to
|
| 412 |
+
hf://buckets/<SLYFOX_COMMUNITY_BUCKET>/runs/*.json
|
| 413 |
+
Each entry is a manifest with {run_id, model_id, source, owner, summary}.
|
| 414 |
+
"""
|
| 415 |
+
try:
|
| 416 |
+
files = list(api.list_bucket_files(COMMUNITY_INDEX_BUCKET, path="runs"))
|
| 417 |
+
except Exception as e: # noqa: BLE001
|
| 418 |
+
log.info("community index not available yet: %s", e)
|
| 419 |
+
return JSONResponse({"runs": [], "note": "community index not initialized"})
|
| 420 |
+
runs: list[dict] = []
|
| 421 |
+
for f in files:
|
| 422 |
+
path = getattr(f, "path", None) or getattr(f, "rfilename", None) or str(f)
|
| 423 |
+
if not path.endswith(".json"):
|
| 424 |
+
continue
|
| 425 |
+
# Read each manifest. The community bucket is *public*, so anonymous
|
| 426 |
+
# reads should work; we still go via HfApi for consistency.
|
| 427 |
+
import tempfile
|
| 428 |
+
|
| 429 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 430 |
+
local = Path(tmp) / "m.json"
|
| 431 |
+
try:
|
| 432 |
+
api.download_bucket_files(COMMUNITY_INDEX_BUCKET, files=[(path, str(local))])
|
| 433 |
+
runs.append(json.loads(local.read_text()))
|
| 434 |
+
except Exception as e: # noqa: BLE001
|
| 435 |
+
log.warning("skip community entry %s: %s", path, e)
|
| 436 |
+
return JSONResponse({"runs": runs})
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
@app.get("/community", include_in_schema=False)
|
| 440 |
+
def community_page() -> FileResponse:
|
| 441 |
+
return FileResponse(ROOT / "community.html")
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
# =============================================================================
|
| 445 |
# Static frontend — mounted last so /api/* routes win.
|
| 446 |
+
# =============================================================================
|
| 447 |
+
|
| 448 |
+
|
| 449 |
@app.get("/")
|
| 450 |
def index() -> FileResponse:
|
| 451 |
return FileResponse(ROOT / "index.html")
|
community.html
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
| 6 |
+
<title>Slyfox · Community</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root {
|
| 9 |
+
--bg: #0d1117; --panel: #161b22; --panel-2: #1c2128; --border: #30363d;
|
| 10 |
+
--text: #e6edf3; --muted: #8b949e; --accent: #2f81f7;
|
| 11 |
+
--ok: #3fb950; --warn: #d29922; --err: #f85149;
|
| 12 |
+
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
| 13 |
+
}
|
| 14 |
+
* { box-sizing: border-box; }
|
| 15 |
+
html, body { background: var(--bg); color: var(--text); margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
| 16 |
+
a { color: var(--accent); text-decoration: none; }
|
| 17 |
+
a:hover { text-decoration: underline; }
|
| 18 |
+
header { display: flex; align-items: center; justify-content: space-between; padding: 14px 24px; border-bottom: 1px solid var(--border); }
|
| 19 |
+
header h1 { font-size: 16px; margin: 0; font-weight: 600; }
|
| 20 |
+
header .nav a { color: var(--muted); margin-left: 18px; font-size: 14px; }
|
| 21 |
+
header .nav a.active { color: var(--text); }
|
| 22 |
+
main { max-width: 1100px; margin: 0 auto; padding: 24px; }
|
| 23 |
+
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 18px 20px; margin-bottom: 18px; }
|
| 24 |
+
.panel h2 { margin: 0 0 10px; font-size: 14px; font-weight: 600; }
|
| 25 |
+
.panel p { color: var(--muted); font-size: 13px; line-height: 1.6; margin: 0 0 10px; }
|
| 26 |
+
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; background: var(--panel-2); color: var(--muted); border: 1px solid var(--border); margin-right: 6px; }
|
| 27 |
+
.runs { display: grid; gap: 10px; }
|
| 28 |
+
.run { background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; }
|
| 29 |
+
.run h3 { margin: 0; font-size: 13px; font-weight: 500; }
|
| 30 |
+
.run h3 .id { color: var(--muted); font-family: var(--mono); font-size: 11px; margin-left: 6px; }
|
| 31 |
+
.run .meta { color: var(--muted); font-size: 11px; margin-top: 4px; }
|
| 32 |
+
.empty { padding: 24px; text-align: center; color: var(--muted); font-size: 13px; }
|
| 33 |
+
pre { background: #0a0d12; border: 1px solid var(--border); padding: 12px; border-radius: 6px; font-family: var(--mono); font-size: 12px; overflow: auto; color: #c9d1d9; }
|
| 34 |
+
</style>
|
| 35 |
+
</head>
|
| 36 |
+
<body>
|
| 37 |
+
<header>
|
| 38 |
+
<h1>🦊 Slyfox · Community</h1>
|
| 39 |
+
<nav class="nav">
|
| 40 |
+
<a href="/">Chat</a>
|
| 41 |
+
<a href="/jobs">Jobs</a>
|
| 42 |
+
<a href="/community" class="active">Community</a>
|
| 43 |
+
</nav>
|
| 44 |
+
</header>
|
| 45 |
+
|
| 46 |
+
<main>
|
| 47 |
+
<section class="panel">
|
| 48 |
+
<h2>What this is <span class="badge">v0 stub</span></h2>
|
| 49 |
+
<p>
|
| 50 |
+
Slyfox is set up so any maintainer can publish their Claude Code traces +
|
| 51 |
+
analysis runs to a public catalog. The idea: shared traces are a high-signal
|
| 52 |
+
training and tuning corpus for open-source models, and they help
|
| 53 |
+
contributors get up to speed on a repo without needing direct access to
|
| 54 |
+
the original maintainer.
|
| 55 |
+
</p>
|
| 56 |
+
<p>
|
| 57 |
+
This page lists opt-in <b>public analysis runs</b> — manifests that
|
| 58 |
+
maintainers chose to share. The browse / remix / leaderboard UX is being
|
| 59 |
+
built; the wiring below is already in place.
|
| 60 |
+
</p>
|
| 61 |
+
<p>
|
| 62 |
+
See <code>COMMUNITY.md</code> in the slyfox repo for the full roadmap and
|
| 63 |
+
the consent / visibility model.
|
| 64 |
+
</p>
|
| 65 |
+
</section>
|
| 66 |
+
|
| 67 |
+
<section class="panel">
|
| 68 |
+
<h2>Public runs</h2>
|
| 69 |
+
<div id="runs" class="runs"><div class="empty">loading…</div></div>
|
| 70 |
+
</section>
|
| 71 |
+
|
| 72 |
+
<section class="panel">
|
| 73 |
+
<h2>How to publish</h2>
|
| 74 |
+
<p>From a clone of a repo whose maintainer has set up a slyfox bucket:</p>
|
| 75 |
+
<pre>hf upload-traces \
|
| 76 |
+
--bucket <org>/<repo>-traces \
|
| 77 |
+
--share community # opt-in: writes a manifest to HF-slyfox/community-index
|
| 78 |
+
|
| 79 |
+
# Then from this Space, run an analysis with `--publish` to add the run to the
|
| 80 |
+
# public catalog. Anyone can browse, fork, or download.</pre>
|
| 81 |
+
<p style="color: var(--warn); font-size: 12px;">
|
| 82 |
+
Note: <code>--share</code> / <code>--publish</code> are part of the
|
| 83 |
+
roadmap, not yet implemented. Don't paste secrets you don't want indexed.
|
| 84 |
+
</p>
|
| 85 |
+
</section>
|
| 86 |
+
</main>
|
| 87 |
+
|
| 88 |
+
<script>
|
| 89 |
+
"use strict";
|
| 90 |
+
async function load() {
|
| 91 |
+
const root = document.getElementById("runs");
|
| 92 |
+
try {
|
| 93 |
+
const res = await fetch("/api/community/runs");
|
| 94 |
+
const data = await res.json();
|
| 95 |
+
const runs = data.runs || [];
|
| 96 |
+
if (!runs.length) {
|
| 97 |
+
root.innerHTML = `<div class="empty">no public runs yet — be the first to publish.</div>`;
|
| 98 |
+
return;
|
| 99 |
+
}
|
| 100 |
+
root.innerHTML = runs.map(r => `
|
| 101 |
+
<div class="run">
|
| 102 |
+
<h3>${esc(r.title || r.model_id || r.run_id)}<span class="id">${esc(r.run_id || "")}</span></h3>
|
| 103 |
+
<div class="meta">
|
| 104 |
+
${r.owner ? `<span class="badge">${esc(r.owner)}</span>` : ""}
|
| 105 |
+
${r.model_id ? `<span class="badge">${esc(r.model_id)}</span>` : ""}
|
| 106 |
+
${r.source ? `<span class="badge">${esc(r.source)}</span>` : ""}
|
| 107 |
+
${r.completed_at || ""}
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
`).join("");
|
| 111 |
+
} catch (e) {
|
| 112 |
+
root.innerHTML = `<div class="empty">failed to load: ${esc(e.message)}</div>`;
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); }
|
| 116 |
+
load();
|
| 117 |
+
setInterval(load, 30000);
|
| 118 |
+
</script>
|
| 119 |
+
</body>
|
| 120 |
+
</html>
|
jobs.html
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
| 6 |
+
<title>Slyfox · Jobs</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root {
|
| 9 |
+
--bg: #0d1117;
|
| 10 |
+
--panel: #161b22;
|
| 11 |
+
--panel-2: #1c2128;
|
| 12 |
+
--border: #30363d;
|
| 13 |
+
--text: #e6edf3;
|
| 14 |
+
--muted: #8b949e;
|
| 15 |
+
--accent: #2f81f7;
|
| 16 |
+
--ok: #3fb950;
|
| 17 |
+
--warn: #d29922;
|
| 18 |
+
--err: #f85149;
|
| 19 |
+
--pending: #6e7681;
|
| 20 |
+
--running: #58a6ff;
|
| 21 |
+
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
| 22 |
+
}
|
| 23 |
+
* { box-sizing: border-box; }
|
| 24 |
+
html, body { background: var(--bg); color: var(--text); margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
| 25 |
+
a { color: var(--accent); text-decoration: none; }
|
| 26 |
+
a:hover { text-decoration: underline; }
|
| 27 |
+
|
| 28 |
+
header {
|
| 29 |
+
display: flex; align-items: center; justify-content: space-between;
|
| 30 |
+
padding: 14px 24px; border-bottom: 1px solid var(--border);
|
| 31 |
+
}
|
| 32 |
+
header h1 { font-size: 16px; margin: 0; font-weight: 600; letter-spacing: 0.2px; }
|
| 33 |
+
header .nav a { color: var(--muted); margin-left: 18px; font-size: 14px; }
|
| 34 |
+
header .nav a.active { color: var(--text); }
|
| 35 |
+
|
| 36 |
+
main { max-width: 1100px; margin: 0 auto; padding: 24px; }
|
| 37 |
+
|
| 38 |
+
.panel {
|
| 39 |
+
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
|
| 40 |
+
padding: 18px 20px; margin-bottom: 18px;
|
| 41 |
+
}
|
| 42 |
+
.panel h2 {
|
| 43 |
+
margin: 0 0 12px; font-size: 14px; font-weight: 600; color: var(--text);
|
| 44 |
+
display: flex; align-items: center; gap: 8px;
|
| 45 |
+
}
|
| 46 |
+
.panel h2 .hint { color: var(--muted); font-weight: 400; font-size: 12px; }
|
| 47 |
+
|
| 48 |
+
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
|
| 49 |
+
label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
| 50 |
+
input[type=text], input[type=number], select {
|
| 51 |
+
width: 100%;
|
| 52 |
+
background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px;
|
| 53 |
+
color: var(--text); padding: 8px 10px; font-size: 13px; font-family: inherit;
|
| 54 |
+
}
|
| 55 |
+
input:focus, select:focus { outline: none; border-color: var(--accent); }
|
| 56 |
+
button {
|
| 57 |
+
background: var(--accent); color: white; border: 0; border-radius: 6px;
|
| 58 |
+
padding: 8px 14px; font-size: 13px; cursor: pointer; font-weight: 500;
|
| 59 |
+
}
|
| 60 |
+
button:hover { filter: brightness(1.1); }
|
| 61 |
+
button.secondary { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); }
|
| 62 |
+
button.danger { background: var(--err); }
|
| 63 |
+
button[disabled] { opacity: 0.5; cursor: not-allowed; }
|
| 64 |
+
|
| 65 |
+
.row-actions { display: flex; gap: 10px; margin-top: 14px; align-items: center; }
|
| 66 |
+
.row-actions .filler { flex: 1; }
|
| 67 |
+
.toast { font-size: 12px; color: var(--muted); }
|
| 68 |
+
.toast.ok { color: var(--ok); }
|
| 69 |
+
.toast.err { color: var(--err); }
|
| 70 |
+
|
| 71 |
+
/* Job list — CI-style rows */
|
| 72 |
+
.jobs { border-radius: 8px; border: 1px solid var(--border); overflow: hidden; }
|
| 73 |
+
.job-row {
|
| 74 |
+
display: grid; grid-template-columns: 28px 24px 1fr auto auto;
|
| 75 |
+
gap: 12px; align-items: center;
|
| 76 |
+
padding: 12px 14px; background: var(--panel); border-bottom: 1px solid var(--border);
|
| 77 |
+
cursor: pointer; transition: background 0.1s;
|
| 78 |
+
}
|
| 79 |
+
.job-row:last-child { border-bottom: 0; }
|
| 80 |
+
.job-row:hover { background: var(--panel-2); }
|
| 81 |
+
.job-row.expanded { background: var(--panel-2); }
|
| 82 |
+
|
| 83 |
+
.dot {
|
| 84 |
+
width: 12px; height: 12px; border-radius: 50%;
|
| 85 |
+
display: inline-block; flex-shrink: 0;
|
| 86 |
+
box-shadow: 0 0 0 2px var(--panel);
|
| 87 |
+
}
|
| 88 |
+
.dot.queued, .dot.pending { background: var(--pending); }
|
| 89 |
+
.dot.running { background: var(--running); animation: pulse 1.4s ease-in-out infinite; }
|
| 90 |
+
.dot.completed, .dot.success { background: var(--ok); }
|
| 91 |
+
.dot.failed, .dot.error { background: var(--err); }
|
| 92 |
+
.dot.cancelled, .dot.canceled { background: var(--warn); }
|
| 93 |
+
.dot.unknown { background: var(--pending); opacity: 0.5; }
|
| 94 |
+
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
| 95 |
+
|
| 96 |
+
.kind { font-size: 16px; opacity: 0.9; text-align: center; }
|
| 97 |
+
.job-title { font-size: 13px; color: var(--text); font-weight: 500; }
|
| 98 |
+
.job-title .id { color: var(--muted); font-family: var(--mono); margin-left: 6px; font-size: 11px; }
|
| 99 |
+
.job-sub { color: var(--muted); font-size: 11px; margin-top: 2px; }
|
| 100 |
+
.job-meta { color: var(--muted); font-size: 12px; font-family: var(--mono); text-align: right; }
|
| 101 |
+
.job-meta .status { display: block; color: var(--text); font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px; }
|
| 102 |
+
.chevron { color: var(--muted); font-size: 14px; transition: transform 0.15s; }
|
| 103 |
+
.job-row.expanded .chevron { transform: rotate(90deg); }
|
| 104 |
+
|
| 105 |
+
.job-detail {
|
| 106 |
+
background: #0a0d12; border-bottom: 1px solid var(--border);
|
| 107 |
+
padding: 14px 18px; font-family: var(--mono); font-size: 12px;
|
| 108 |
+
display: none;
|
| 109 |
+
}
|
| 110 |
+
.job-row.expanded + .job-detail { display: block; }
|
| 111 |
+
|
| 112 |
+
.job-detail .params { color: var(--muted); margin-bottom: 10px; line-height: 1.6; }
|
| 113 |
+
.job-detail .params b { color: var(--text); font-weight: 500; }
|
| 114 |
+
.job-detail .logs {
|
| 115 |
+
background: #000; border: 1px solid var(--border); border-radius: 6px;
|
| 116 |
+
padding: 10px 12px; max-height: 280px; overflow: auto; white-space: pre-wrap;
|
| 117 |
+
color: #c9d1d9; line-height: 1.5;
|
| 118 |
+
}
|
| 119 |
+
.job-detail .actions { display: flex; gap: 8px; margin-top: 10px; }
|
| 120 |
+
|
| 121 |
+
.empty { padding: 24px; text-align: center; color: var(--muted); font-size: 13px; }
|
| 122 |
+
</style>
|
| 123 |
+
</head>
|
| 124 |
+
<body>
|
| 125 |
+
<header>
|
| 126 |
+
<h1>🦊 Slyfox · Jobs</h1>
|
| 127 |
+
<nav class="nav">
|
| 128 |
+
<a href="/">Chat</a>
|
| 129 |
+
<a href="/jobs" class="active">Jobs</a>
|
| 130 |
+
<a href="/community">Community</a>
|
| 131 |
+
</nav>
|
| 132 |
+
</header>
|
| 133 |
+
|
| 134 |
+
<main>
|
| 135 |
+
<!-- Request model -->
|
| 136 |
+
<section class="panel" id="panel-extract">
|
| 137 |
+
<h2>Request a new model <span class="hint">runs emotion-vector extraction on hf jobs</span></h2>
|
| 138 |
+
<div class="grid">
|
| 139 |
+
<div>
|
| 140 |
+
<label for="ex-model">model id</label>
|
| 141 |
+
<input id="ex-model" type="text" placeholder="Qwen/Qwen2.5-7B-Instruct" />
|
| 142 |
+
</div>
|
| 143 |
+
<div>
|
| 144 |
+
<label for="ex-flavor">hardware</label>
|
| 145 |
+
<select id="ex-flavor">
|
| 146 |
+
<option value="a10g-large">a10g-large (default)</option>
|
| 147 |
+
<option value="a10g-small">a10g-small</option>
|
| 148 |
+
<option value="a100-large">a100-large</option>
|
| 149 |
+
<option value="cpu-upgrade">cpu-upgrade</option>
|
| 150 |
+
</select>
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
<div class="row-actions">
|
| 154 |
+
<button id="ex-submit">Submit extraction</button>
|
| 155 |
+
<span class="toast" id="ex-toast"></span>
|
| 156 |
+
</div>
|
| 157 |
+
</section>
|
| 158 |
+
|
| 159 |
+
<!-- Analyze traces -->
|
| 160 |
+
<section class="panel" id="panel-analyze">
|
| 161 |
+
<h2>Analyze traces <span class="hint">replays sessions through a model, emits per-turn emotion trajectories</span></h2>
|
| 162 |
+
<div class="grid">
|
| 163 |
+
<div>
|
| 164 |
+
<label for="an-source">traces bucket</label>
|
| 165 |
+
<input id="an-source" type="text" placeholder="HF-slyfox/traces" value="HF-slyfox/traces" />
|
| 166 |
+
</div>
|
| 167 |
+
<div>
|
| 168 |
+
<label for="an-prefix">prefix (optional)</label>
|
| 169 |
+
<input id="an-prefix" type="text" placeholder="Arthurs-beast/-Users-...-transformers" />
|
| 170 |
+
</div>
|
| 171 |
+
<div>
|
| 172 |
+
<label for="an-n">num traces</label>
|
| 173 |
+
<input id="an-n" type="number" min="1" max="100" value="5" />
|
| 174 |
+
</div>
|
| 175 |
+
<div>
|
| 176 |
+
<label for="an-model">model</label>
|
| 177 |
+
<input id="an-model" type="text" placeholder="google/gemma-2-2b-it" />
|
| 178 |
+
</div>
|
| 179 |
+
<div>
|
| 180 |
+
<label for="an-flavor">hardware</label>
|
| 181 |
+
<select id="an-flavor">
|
| 182 |
+
<option value="a10g-large">a10g-large</option>
|
| 183 |
+
<option value="a10g-small">a10g-small</option>
|
| 184 |
+
<option value="a100-large">a100-large</option>
|
| 185 |
+
</select>
|
| 186 |
+
</div>
|
| 187 |
+
</div>
|
| 188 |
+
<div class="row-actions">
|
| 189 |
+
<button id="an-submit">Submit analysis</button>
|
| 190 |
+
<span class="toast" id="an-toast"></span>
|
| 191 |
+
</div>
|
| 192 |
+
</section>
|
| 193 |
+
|
| 194 |
+
<!-- Job list -->
|
| 195 |
+
<section>
|
| 196 |
+
<h2 style="font-size: 14px; font-weight: 600; margin: 0 0 10px;">Recent runs</h2>
|
| 197 |
+
<div class="jobs" id="jobs"></div>
|
| 198 |
+
</section>
|
| 199 |
+
</main>
|
| 200 |
+
|
| 201 |
+
<script>
|
| 202 |
+
"use strict";
|
| 203 |
+
|
| 204 |
+
// --------------------------------------------------------------------------
|
| 205 |
+
// Tiny per-browser user id, persisted in localStorage. Mirrors index.html.
|
| 206 |
+
// --------------------------------------------------------------------------
|
| 207 |
+
|
| 208 |
+
const USER_KEY = "slyfox.user_id";
|
| 209 |
+
function userId() {
|
| 210 |
+
let id = localStorage.getItem(USER_KEY);
|
| 211 |
+
if (!id) {
|
| 212 |
+
id = "u-" + crypto.randomUUID().replaceAll("-", "").slice(0, 16);
|
| 213 |
+
localStorage.setItem(USER_KEY, id);
|
| 214 |
+
}
|
| 215 |
+
return id;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
async function api(path, opts = {}) {
|
| 219 |
+
const headers = { "X-User-Id": userId(), ...(opts.headers || {}) };
|
| 220 |
+
if (opts.body && typeof opts.body !== "string") {
|
| 221 |
+
headers["Content-Type"] = "application/json";
|
| 222 |
+
opts.body = JSON.stringify(opts.body);
|
| 223 |
+
}
|
| 224 |
+
const res = await fetch(path, { ...opts, headers });
|
| 225 |
+
if (!res.ok) {
|
| 226 |
+
let err;
|
| 227 |
+
try { err = (await res.json()).detail || res.statusText; } catch { err = res.statusText; }
|
| 228 |
+
throw new Error(err);
|
| 229 |
+
}
|
| 230 |
+
return res.json();
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
// --------------------------------------------------------------------------
|
| 234 |
+
// Submit handlers
|
| 235 |
+
// --------------------------------------------------------------------------
|
| 236 |
+
|
| 237 |
+
function bindSubmit(buttonId, toastId, build, endpoint) {
|
| 238 |
+
const btn = document.getElementById(buttonId);
|
| 239 |
+
const toast = document.getElementById(toastId);
|
| 240 |
+
btn.addEventListener("click", async () => {
|
| 241 |
+
btn.disabled = true;
|
| 242 |
+
toast.textContent = "submitting…";
|
| 243 |
+
toast.className = "toast";
|
| 244 |
+
try {
|
| 245 |
+
const body = build();
|
| 246 |
+
const job = await api(endpoint, { method: "POST", body });
|
| 247 |
+
toast.textContent = "queued: " + job.job_id;
|
| 248 |
+
toast.className = "toast ok";
|
| 249 |
+
refreshJobs();
|
| 250 |
+
} catch (e) {
|
| 251 |
+
toast.textContent = "✗ " + e.message;
|
| 252 |
+
toast.className = "toast err";
|
| 253 |
+
} finally {
|
| 254 |
+
btn.disabled = false;
|
| 255 |
+
}
|
| 256 |
+
});
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
bindSubmit("ex-submit", "ex-toast", () => ({
|
| 260 |
+
model_id: document.getElementById("ex-model").value.trim(),
|
| 261 |
+
flavor: document.getElementById("ex-flavor").value,
|
| 262 |
+
}), "/api/models/extract");
|
| 263 |
+
|
| 264 |
+
bindSubmit("an-submit", "an-toast", () => ({
|
| 265 |
+
source: document.getElementById("an-source").value.trim(),
|
| 266 |
+
prefix: document.getElementById("an-prefix").value.trim(),
|
| 267 |
+
n_traces: parseInt(document.getElementById("an-n").value, 10),
|
| 268 |
+
model_id: document.getElementById("an-model").value.trim(),
|
| 269 |
+
flavor: document.getElementById("an-flavor").value,
|
| 270 |
+
}), "/api/analyses");
|
| 271 |
+
|
| 272 |
+
// --------------------------------------------------------------------------
|
| 273 |
+
// Job list rendering
|
| 274 |
+
// --------------------------------------------------------------------------
|
| 275 |
+
|
| 276 |
+
const jobsRoot = document.getElementById("jobs");
|
| 277 |
+
const expanded = new Set();
|
| 278 |
+
const logCache = new Map();
|
| 279 |
+
let logTimer = null;
|
| 280 |
+
|
| 281 |
+
const KIND_ICON = { extract: "🧠", analyze: "🧪" };
|
| 282 |
+
|
| 283 |
+
function statusClass(s) {
|
| 284 |
+
if (!s) return "unknown";
|
| 285 |
+
s = String(s).toLowerCase();
|
| 286 |
+
if (s.includes("run")) return "running";
|
| 287 |
+
if (s.includes("queue") || s.includes("pend") || s.includes("start")) return "queued";
|
| 288 |
+
if (s.includes("complete") || s.includes("succ") || s.includes("done")) return "completed";
|
| 289 |
+
if (s.includes("fail") || s.includes("err")) return "failed";
|
| 290 |
+
if (s.includes("cancel")) return "cancelled";
|
| 291 |
+
return "unknown";
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
function fmtElapsed(s) {
|
| 295 |
+
if (!s && s !== 0) return "—";
|
| 296 |
+
const m = Math.floor(s / 60), sec = s % 60;
|
| 297 |
+
if (m === 0) return sec + "s";
|
| 298 |
+
if (m < 60) return m + "m " + sec + "s";
|
| 299 |
+
const h = Math.floor(m / 60);
|
| 300 |
+
return h + "h " + (m % 60) + "m";
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
function jobTitle(j) {
|
| 304 |
+
if (j.kind === "extract") return "Extract " + (j.params?.model_id || "?");
|
| 305 |
+
if (j.kind === "analyze") return "Analyze " + (j.params?.source || "?") + " · " + (j.params?.model_id || "?");
|
| 306 |
+
return j.job_id;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
function jobSubtitle(j) {
|
| 310 |
+
if (j.kind === "extract") return "→ " + (j.params?.output || "");
|
| 311 |
+
if (j.kind === "analyze") {
|
| 312 |
+
const n = j.params?.n_traces ?? "?";
|
| 313 |
+
return n + " traces → " + (j.params?.output || "");
|
| 314 |
+
}
|
| 315 |
+
return "";
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
function paramRow(label, value) {
|
| 319 |
+
if (!value) return "";
|
| 320 |
+
return `<div><b>${label}</b>: ${escapeHtml(String(value))}</div>`;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
function escapeHtml(s) {
|
| 324 |
+
return s.replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
function render(jobs) {
|
| 328 |
+
if (!jobs.length) {
|
| 329 |
+
jobsRoot.innerHTML = `<div class="empty">No jobs yet — submit one above to get started.</div>`;
|
| 330 |
+
return;
|
| 331 |
+
}
|
| 332 |
+
jobsRoot.innerHTML = jobs.map(j => {
|
| 333 |
+
const sCls = statusClass(j.live?.status);
|
| 334 |
+
const elapsed = fmtElapsed(j.live?.elapsed_s);
|
| 335 |
+
const isOpen = expanded.has(j.job_id);
|
| 336 |
+
const logs = (logCache.get(j.job_id) || []).join("\n");
|
| 337 |
+
const canCancel = sCls === "running" || sCls === "queued";
|
| 338 |
+
return `
|
| 339 |
+
<div class="job-row ${isOpen ? "expanded" : ""}" data-id="${j.job_id}">
|
| 340 |
+
<span class="dot ${sCls}" title="${j.live?.status ?? "unknown"}"></span>
|
| 341 |
+
<span class="kind">${KIND_ICON[j.kind] || "▶"}</span>
|
| 342 |
+
<div>
|
| 343 |
+
<div class="job-title">${escapeHtml(jobTitle(j))}<span class="id">${j.job_id.slice(0, 12)}</span></div>
|
| 344 |
+
<div class="job-sub">${escapeHtml(jobSubtitle(j))}</div>
|
| 345 |
+
</div>
|
| 346 |
+
<div class="job-meta"><span class="status">${(j.live?.status || "unknown")}</span>${elapsed}</div>
|
| 347 |
+
<span class="chevron">▶</span>
|
| 348 |
+
</div>
|
| 349 |
+
<div class="job-detail" data-id="${j.job_id}">
|
| 350 |
+
<div class="params">
|
| 351 |
+
${paramRow("submitted", j.submitted_at)}
|
| 352 |
+
${paramRow("requester", j.requester)}
|
| 353 |
+
${paramRow("flavor", j.params?.flavor || j.live?.flavor)}
|
| 354 |
+
${paramRow("started", j.live?.started_at)}
|
| 355 |
+
${paramRow("ended", j.live?.ended_at)}
|
| 356 |
+
${j.kind === "analyze" ? paramRow("source", j.params?.source) : ""}
|
| 357 |
+
${j.kind === "analyze" ? paramRow("prefix", j.params?.prefix) : ""}
|
| 358 |
+
${j.kind === "analyze" ? paramRow("n_traces", j.params?.n_traces) : ""}
|
| 359 |
+
${paramRow("output", j.params?.output)}
|
| 360 |
+
</div>
|
| 361 |
+
<div class="logs" id="logs-${j.job_id}">${escapeHtml(logs) || "<i style=\\"color:#666\\">no logs yet</i>"}</div>
|
| 362 |
+
<div class="actions">
|
| 363 |
+
<button class="secondary" data-act="refresh-logs" data-id="${j.job_id}">Refresh logs</button>
|
| 364 |
+
${canCancel ? `<button class="danger" data-act="cancel" data-id="${j.job_id}">Cancel</button>` : ""}
|
| 365 |
+
</div>
|
| 366 |
+
</div>
|
| 367 |
+
`;
|
| 368 |
+
}).join("");
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
jobsRoot.addEventListener("click", async (ev) => {
|
| 372 |
+
const actBtn = ev.target.closest("button[data-act]");
|
| 373 |
+
if (actBtn) {
|
| 374 |
+
const id = actBtn.dataset.id;
|
| 375 |
+
if (actBtn.dataset.act === "refresh-logs") { await refreshLogs(id); return; }
|
| 376 |
+
if (actBtn.dataset.act === "cancel") {
|
| 377 |
+
if (!confirm("Cancel job " + id + "?")) return;
|
| 378 |
+
try {
|
| 379 |
+
await api(`/api/jobs/${id}/cancel`, { method: "POST" });
|
| 380 |
+
refreshJobs();
|
| 381 |
+
} catch (e) { alert("cancel failed: " + e.message); }
|
| 382 |
+
}
|
| 383 |
+
return;
|
| 384 |
+
}
|
| 385 |
+
const row = ev.target.closest(".job-row");
|
| 386 |
+
if (!row) return;
|
| 387 |
+
const id = row.dataset.id;
|
| 388 |
+
if (expanded.has(id)) expanded.delete(id);
|
| 389 |
+
else { expanded.add(id); await refreshLogs(id); }
|
| 390 |
+
refreshJobs(/* keepOpen */ true);
|
| 391 |
+
});
|
| 392 |
+
|
| 393 |
+
async function refreshLogs(id) {
|
| 394 |
+
try {
|
| 395 |
+
const data = await api(`/api/jobs/${id}/logs?tail=200`);
|
| 396 |
+
logCache.set(id, data.lines || []);
|
| 397 |
+
const el = document.getElementById("logs-" + id);
|
| 398 |
+
if (el) {
|
| 399 |
+
el.textContent = (data.lines || []).join("\n");
|
| 400 |
+
el.scrollTop = el.scrollHeight;
|
| 401 |
+
}
|
| 402 |
+
} catch (e) {
|
| 403 |
+
const el = document.getElementById("logs-" + id);
|
| 404 |
+
if (el) el.textContent = "(logs: " + e.message + ")";
|
| 405 |
+
}
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
let inFlight = false;
|
| 409 |
+
async function refreshJobs(_keepOpen = false) {
|
| 410 |
+
if (inFlight) return;
|
| 411 |
+
inFlight = true;
|
| 412 |
+
try {
|
| 413 |
+
const jobs = await api("/api/jobs");
|
| 414 |
+
render(jobs);
|
| 415 |
+
} catch (e) {
|
| 416 |
+
jobsRoot.innerHTML = `<div class="empty" style="color:var(--err)">${escapeHtml(e.message)}</div>`;
|
| 417 |
+
} finally { inFlight = false; }
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
// Initial load + polling.
|
| 421 |
+
refreshJobs();
|
| 422 |
+
setInterval(refreshJobs, 5000);
|
| 423 |
+
|
| 424 |
+
// Refresh logs for expanded rows on a tighter cadence.
|
| 425 |
+
setInterval(() => expanded.forEach(id => refreshLogs(id)), 3000);
|
| 426 |
+
</script>
|
| 427 |
+
</body>
|
| 428 |
+
</html>
|