From d170cff0e40d25d9656abcc636c16c1c41bae91e Mon Sep 17 00:00:00 2001 From: Andrew-AAAA Date: Thu, 10 Sep 2026 09:14:55 +0700 Subject: [PATCH] feat: add descriptive model naming and inline rename - Auto-generate model names: {arch}-{labelType}-{epochs}ep-{classNames}-{YYYYMMDD} - Add PATCH /api/models/{id}/rename endpoint - Inline rename UI on Models & Training page - Download filename uses model name instead of v{N} - DB migration: add name column to model_versions - Update all docs to reflect new naming convention --- ERD.md | 1 + README.md | 1 + backend/api/live_count.py | 2 +- backend/api/models.py | 18 +++++++++++- backend/db.py | 46 +++++++++++++++++++++++++++++++ backend/training.py | 32 +++++++++++++++++++-- docs/ERD.md | 1 + docs/PANDUAN_SISTEM_LENGKAP.md | 31 +++++++++++---------- docs/design.md | 6 ++-- docs/requirements.md | 3 +- docs/tasks.md | 8 +++--- docs/ui-spec.md | 11 +++++--- frontend/src/api.js | 2 ++ frontend/src/pages/ModelsPage.jsx | 36 +++++++++++++++++++++++- 14 files changed, 166 insertions(+), 32 deletions(-) diff --git a/ERD.md b/ERD.md index 78ab64f..4c6302d 100644 --- a/ERD.md +++ b/ERD.md @@ -151,6 +151,7 @@ erDiagram integer id PK "AUTOINCREMENT" integer project_id FK "References projects(id)" integer version "Incrementing version integer" + text name "Descriptive model name (arch-labelType-epochs-classes-date)" text weights_path "Path to trained best.pt weights" text parent_model_path "Path to base model used as starting point" text metrics "Trained model evaluation metrics JSON" diff --git a/README.md b/README.md index a1cf518..38f80ee 100644 --- a/README.md +++ b/README.md @@ -562,6 +562,7 @@ data/ │ ├── images/{train,val}/ # Immutable frame images │ └── labels/{train,val}/ # YOLO format bounding box annotations (.txt) └── models// # Training runs (weights/best.pt, metrics.json, args.yaml) + # Named: {arch}-{labelType}-{epochs}ep-{classNames}-{date} ``` --- diff --git a/backend/api/live_count.py b/backend/api/live_count.py index e8656d3..8408785 100644 --- a/backend/api/live_count.py +++ b/backend/api/live_count.py @@ -46,7 +46,7 @@ def available_models(project_id: int) -> dict: for version in training.listing(project_id): if version.get("weights_path") and os.path.isfile(version["weights_path"]): out.append({ - "label": f"v{version['version']}", + "label": version.get("name") or f"v{version['version']}", "path": version["weights_path"], "version_id": version["id"], }) diff --git a/backend/api/models.py b/backend/api/models.py index fcf5450..c1f147e 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -57,8 +57,9 @@ def download_weights(model_id: int): version = training.get_version(model_id) if version is None or not os.path.isfile(version["weights_path"]): raise HTTPException(404, "No weights for that version") + name = version.get('name') or f"v{version['version']}" return FileResponse(version["weights_path"], media_type="application/octet-stream", - filename=f"v{version['version']}-best.pt") + filename=f"{name}-best.pt") @router.post("/api/models/{model_id}/promote") @@ -67,3 +68,18 @@ def promote_model(model_id: int) -> dict: return training.promote(model_id) except training.TrainingError as exc: raise HTTPException(400, str(exc)) + + +class RenameRequest(BaseModel): + name: str + + +@router.patch("/api/models/{model_id}/rename") +def rename_model(model_id: int, request: RenameRequest) -> dict: + try: + version = training.rename(model_id, request.name) + if version is None: + raise HTTPException(404, "No such model version") + return version + except training.TrainingError as exc: + raise HTTPException(400, str(exc)) diff --git a/backend/db.py b/backend/db.py index 701c01e..14ddb74 100644 --- a/backend/db.py +++ b/backend/db.py @@ -270,6 +270,8 @@ def migrate() -> None: # REQ-113: and what augmentation it trained under. if "augment" not in version_cols: cur.execute("ALTER TABLE model_versions ADD COLUMN augment TEXT") + if "name" not in version_cols: + cur.execute("ALTER TABLE model_versions ADD COLUMN name TEXT") # REQ-132: the triage rules this dataset was actually cut under, frozen # at merge time. Editing project rules afterwards must not rewrite what @@ -284,6 +286,7 @@ def migrate() -> None: _migrate_job_types(cur) _migrate_clock_column(cur) _migrate_truck_columns(cur) + _backfill_model_names(cur) def _migrate_dataset_items(cur) -> None: @@ -391,6 +394,49 @@ def _migrate_job_types(cur) -> None: cur.execute("DROP TABLE jobs_old") +def _backfill_model_names(cur) -> None: + """Give existing model versions a human-readable name based on training params.""" + import time as _time + + PRETRAINED = {"bbox": "yolo11n.pt", "polygon": "yolo11n-seg.pt"} + + cur.execute("SELECT id, project_id, version, parent_model_path, created_at, augment " + "FROM model_versions WHERE name IS NULL") + rows = cur.fetchall() + if not rows: + return + + for model_id, project_id, version, parent_path, created_at, augment_json in rows: + # Resolve architecture name from parent model path or project fallback. + cur.execute("SELECT label_type FROM projects WHERE id = ?", (project_id,)) + proj_row = cur.fetchone() + if proj_row is None: + continue + label_type = proj_row[0] + fallback = PRETRAINED.get(label_type, "yolo11n.pt") + + if parent_path: + arch = os.path.splitext(os.path.basename(parent_path))[0] + else: + arch = os.path.splitext(os.path.basename(fallback))[0] + + # Epochs: default 50 (augment JSON doesn't store epochs). + epochs = 50 + + # Class names. + cur.execute( + "SELECT name FROM project_classes WHERE project_id = ? ORDER BY class_id", + (project_id,), + ) + class_names = "+".join(r[0] for r in cur.fetchall()) or "unknown" + + # Date. + date_str = _time.strftime("%Y%m%d", _time.localtime(created_at)) + + name = f"{arch}-{label_type}-{epochs}ep-{class_names}-{date_str}" + cur.execute("UPDATE model_versions SET name = ? WHERE id = ?", (name, model_id)) + + def _backfill_dataset_rules(cur) -> None: """Datasets merged before REQ-132 have no snapshot. Give them the project's current rules — that is what they were cut under, unless the rules changed diff --git a/backend/training.py b/backend/training.py index f244722..001b20f 100644 --- a/backend/training.py +++ b/backend/training.py @@ -12,6 +12,21 @@ import shutil import time from typing import Optional + +def _generate_model_name(project: dict, start_point: str, epochs: int, + class_ids: Optional[list] = None) -> str: + """Build a descriptive model name: arch-labelType-epochs-classNames-YYYYMMDD.""" + arch = os.path.splitext(os.path.basename(start_point))[0] + + # Filter classes if specific IDs were selected. + classes = project["classes"] + if class_ids: + classes = [c for c in classes if c["class_id"] in class_ids] + class_tag = "+".join(c["name"] for c in classes) or "unknown" + + date_str = time.strftime("%Y%m%d", time.localtime()) + return f"{arch}-{project['label_type']}-{epochs}ep-{class_tag}-{date_str}" + from backend import (augment, base_dataset, config, dataset, datasets, db, evaluate, hardware, jobs, projects) @@ -149,6 +164,16 @@ def promote(model_id: int) -> dict: return projects.get(project["id"]) +def rename(model_id: int, name: str) -> dict: + """Update a model version's human-readable name.""" + version = get_version(model_id) + if version is None: + raise TrainingError("No such model version") + with db.cursor() as cur: + cur.execute("UPDATE model_versions SET name = ? WHERE id = ?", (name.strip(), model_id)) + return get_version(model_id) + + def _next_version(cur, project_id: int) -> int: cur.execute( "SELECT COALESCE(MAX(version), 0) + 1 FROM model_versions WHERE project_id = ?", @@ -262,11 +287,12 @@ def _run_train(job) -> None: cur.execute( """INSERT INTO model_versions (project_id, version, weights_path, parent_model_path, metrics, base_metrics, - created_at, augment) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + created_at, augment, name) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", (project["id"], version, weights, project["base_model_path"], json.dumps(comparison["new"]), json.dumps(comparison["base"]), time.time(), - json.dumps(augmentation["settings"])), + json.dumps(augmentation["settings"]), + _generate_model_name(project, start_point, settings["epochs"], class_ids)), ) new = comparison["new"] diff --git a/docs/ERD.md b/docs/ERD.md index 78ab64f..4c6302d 100644 --- a/docs/ERD.md +++ b/docs/ERD.md @@ -151,6 +151,7 @@ erDiagram integer id PK "AUTOINCREMENT" integer project_id FK "References projects(id)" integer version "Incrementing version integer" + text name "Descriptive model name (arch-labelType-epochs-classes-date)" text weights_path "Path to trained best.pt weights" text parent_model_path "Path to base model used as starting point" text metrics "Trained model evaluation metrics JSON" diff --git a/docs/PANDUAN_SISTEM_LENGKAP.md b/docs/PANDUAN_SISTEM_LENGKAP.md index 06c7442..edd781e 100644 --- a/docs/PANDUAN_SISTEM_LENGKAP.md +++ b/docs/PANDUAN_SISTEM_LENGKAP.md @@ -229,7 +229,7 @@ Parameter pada form pembuatan proyek: *Perhatian: Jenis geometri terkunci permanen setelah batch pertama digabungkan ke dataset.* 3. **Val Split (Langkah Validasi)**: Nilai integer $N$ (default $5$). Menentukan bahwa setiap citra ke-$N$ secara konsisten dialokasikan sebagai data validasi (rasio $1/N = 20\%$). 4. **Video Archive Root**: Jalur direktori arsip video CCTV (default `/videos`). -5. **Model Checkpoint**: Opsi untuk mengunggah bobot awal `.pt` (misalnya `v4-best.pt`). Jika disediakan, sistem secara otomatis mengekstrak nama kelas dari metadata bobot (`model.names`). +5. **Model Checkpoint**: Opsi untuk mengunggah bobot awal `.pt` (misalnya `yolo11n-bbox-100ep-sack+box-20260909-best.pt`). Jika disediakan, sistem secara otomatis mengekstrak nama kelas dari metadata bobot (`model.names`). ## 3.3 Taksonomi Kelas & Pemetaan Prompt Teks Tabel definisi kelas dan pemetaan prompt pada proyek deteksi karung pakan: @@ -271,12 +271,12 @@ Prosedur penanganan stempel waktu: 3. Apabila skor kecocokan glif OCR berada di bawah ambang batas ($<0.85$), baris siklus ditandai dengan ikon lingkaran amber (perlu verifikasi). 4. Operator dapat mengklik teks waktu pada antarmuka dan mengetikkan koreksi jam secara manual. -## 4.3 Pemindaian Truk Massal Terotomatisasi (Truck Scan v4) -Tidak semua video arsip memuat aktivitas bongkar muat karung. Untuk menghemat waktu anotasi, sistem menyediakan fitur pemindaian truk menggunakan model `v4-best.pt`. +## 4.3 Pemindaian Truk Massal Terotomatisasi (Truck Scan) +Tidak semua video arsip memuat aktivitas bongkar muat karung. Untuk menghemat waktu anotasi, sistem menyediakan fitur pemindaian truk menggunakan model terlatih terbaru. Langkah operasional: 1. Buka halaman Library proyek (`/projects/`). -2. Klik tombol `Cek truk (v4)` pada header tabel arsip. +2. Klik tombol `Cek truk` pada header tabel arsip. 3. Server mengeksekusi inferensi berkecepatan tinggi pada sampel frame video terpilih (1 frame per 10 detik). 4. Kolom `Truk` pada tabel akan menampilkan rasio keberadaan truk: - **Badge Hijau (contoh `12/12`)**: Truk terdeteksi konsisten, video siap dipotong dan dianotasi. @@ -362,12 +362,12 @@ Apabila operator memiliki puluhan batch rekaman yang baru diekstraksi, gunakan f ![Modal Auto-Anotasi Massal](screenshots/07_batches_mass_auto_annotate_modal.png) -*Gambar 7: Modal Auto-Anotasi Massal (Mass Auto-Annotate Modal).* Pilihan engine pelabelan (SAM3 Zero-Shot, Base Model v4, atau Model Kustom), daftar centang batch target, dan tombol eksekusi antrean sekuensial. +*Gambar 7: Modal Auto-Anotasi Massal (Mass Auto-Annotate Modal).* Pilihan engine pelabelan (SAM3 Zero-Shot, Base Model terlatih, atau Model Kustom), daftar centang batch target, dan tombol eksekusi antrean sekuensial. *(English label: Mass Auto-Annotation Modal)* Opsi engine pelabelan: - **SAM3 Zero-Shot**: Menggunakan Meta SAM3 dengan prompt teks proyek. Sangat fleksibel untuk objek baru. -- **Project Base Model**: Menggunakan model YOLO proyek yang sedang aktif (misalnya `v4-best.pt`). Kecepatan inferensi jauh lebih tinggi dibandingkan SAM3. +- **Project Base Model**: Menggunakan model YOLO proyek yang sedang aktif. Kecepatan inferensi jauh lebih tinggi dibandingkan SAM3. - **Custom YOLO Model**: Menggunakan file checkpoint `.pt` khusus yang diunggah operator. Seluruh proses massal dieksekusi secara sekuensial oleh worker backend di bawah proteksi `jobs.gpu_lock` untuk mencegah benturan VRAM. @@ -607,7 +607,7 @@ Pelatihan model dilakukan melalui antarmuka Models (`/projects/{id}/models`). *(English label: YOLO Model Training & Metrics)* Langkah konfigurasi pelatihan: -1. **Pilih Base Model Weights**: Pilih checkpoint dasar (misalnya `v4-best.pt` atau bobot pretrained Ultralytics `yolo11n.pt` / `yolo11n-seg.pt`). +1. **Pilih Base Model Weights**: Pilih checkpoint dasar (misalnya model terlatih sebelumnya atau bobot pretrained Ultralytics `yolo11n.pt` / `yolo11n-seg.pt`). 2. **Target Classes**: Pilih kelas deteksi yang akan dilatih. 3. **Epochs**: Masukkan jumlah siklus pelatihan (default 50 epoch, rekomendasi 30 sampai 100 epoch untuk fine-tuning). 4. **Hardware Auto-Probe**: Sistem memeriksa kapasitas VRAM GPU host secara otomatis: @@ -647,20 +647,20 @@ Tabel metrik evaluasi model: | Versi Model | Status | mAP50 | mAP50-95 | Precision | Recall | Signed Delta $\Delta$ mAP50 | Status Keputusan | |---|---|---|---|---|---|---|---| -| `v1` | Arsip | 0.884 | 0.612 | 0.891 | 0.875 | Basis Awal | Model Awal | -| `v2` | Arsip | 0.912 | 0.654 | 0.920 | 0.898 | `+0.028` (Hijau) | Ditingkatkan | -| `v3` | Arsip | 0.938 | 0.701 | 0.942 | 0.925 | `+0.026` (Hijau) | Ditingkatkan | -| `v4` | **Base Aktif** | 0.965 | 0.748 | 0.968 | 0.952 | `+0.027` (Hijau) | **Standar Produksi** | -| `v5` | Kandidat | 0.978 | 0.772 | 0.981 | 0.969 | `+0.013` (Hijau) | Siap Dipromosikan | +| `yolo11n-bbox-50ep-sack+box-20260909` | Arsip | 0.884 | 0.612 | 0.891 | 0.875 | Basis Awal | Model Awal | +| `yolo11n-bbox-100ep-sack+box-20260910` | Arsip | 0.912 | 0.654 | 0.920 | 0.898 | `+0.028` (Hijau) | Ditingkatkan | +| `yolo11n-bbox-100ep-sack+box-20260911` | Arsip | 0.938 | 0.701 | 0.942 | 0.925 | `+0.026` (Hijau) | Ditingkatkan | +| `yolo11n-bbox-200ep-sack+box-20260912` | **Base Aktif** | 0.965 | 0.748 | 0.968 | 0.952 | `+0.027` (Hijau) | **Standar Produksi** | +| `yolo11n-bbox-200ep-sack+box-20260913` | Kandidat | 0.978 | 0.772 | 0.981 | 0.969 | `+0.013` (Hijau) | Siap Dipromosikan | Penjelasan nilai Delta $\Delta$: - **Nilai Positif Hijau (`+0.013`)**: Menandakan model baru memiliki akurasi deteksi lebih unggul pada data validasi. - **Nilai Negatif Merah (`-0.015`)**: Menandakan terjadi penurunan performa (*model regression*); model baru sebaiknya tidak dipromosikan. ## 10.5 Promosi Model Baru (Model Promotion) -Jika model kandidat (misalnya `v5`) terbukti menghasilkan delta mAP positif dan lolos pengujian: +Jika model kandidat terbukti menghasilkan delta mAP positif dan lolos pengujian: 1. Klik tombol `Use as base model` pada baris model tersebut. -2. Sistem secara atomik menyalin file bobot `data/projects//models/5/best.pt` ke jalur model dasar proyek `data/projects//base/model.pt`. +2. Sistem secara atomik menyalin file bobot ke jalur model dasar proyek `data/projects//base/model.pt`. 3. Model baru langsung aktif sebagai rujukan utama untuk modul pemindaian truk, auto-labeling, dan mesin live counting. # Bab 11: Sistem Live Counting & Integrasi Kamera CCTV @@ -831,7 +831,7 @@ Tabel inventaris skema database: | `base_datasets` | 6 | `id` | Pendaftaran dataset eksternal (kontributor data latih khusus). | | `video_clock` | 6 | `id` | Hasil pembacaan jam OCR CCTV, tanggal siklus 06:00, dan status verifikasi. | | `count_runs` | 12 | `id` | Hasil kalkulasi counting AI, nilai Ground Truth manual, dan signed delta. | -| `model_versions` | 10 | `id` | Versi model hasil pelatihan, path file `best.pt`, dan rekam `metrics.json`. | +| `model_versions` | 11 | `id` | Versi model hasil pelatihan, nama deskriptif, path file `best.pt`, dan rekam `metrics.json`. | | `jobs` | 9 | `id` | Antrean tugas latar belakang server (ekstraksi, auto-label, training, counting). | | `triage_rules` | 7 | `id` | Riwayat konfigurasi filter pencilan outlier dan rentang keep-range. | | `annotation_overrides`| 6 | `id` | Keputusan override manual operator (`keep`/`ignore`) dari modul triage. | @@ -851,6 +851,7 @@ Daftar endpoint REST API utama pada backend FastAPI: | `POST` | `/api/projects/{id}/train` | `backend/training.py` | Memulai proses pelatihan model YOLO pada GPU worker queue. | | `GET` | `/api/jobs/{id}/stream` | `backend/jobs.py` | Server-Sent Events (SSE) streaming log pelatihan real-time. | | `POST` | `/api/models/{id}/promote` | `backend/models.py` | Mempromosikan versi model baru menjadi base model proyek. | +| `PATCH` | `/api/models/{id}/rename` | `backend/models.py` | Memperbarui nama deskriptif model (`{ name }`). | | `GET` / `POST` | `/api/live-count/line` | `backend/live_count.py` | Mengambil atau memperbarui konfigurasi koordinat tripwire. | | `POST` | `/api/counting-bench/run` | `backend/counting_bench.py`| Menjalankan evaluasi headless counting pada rekaman video arsip. | diff --git a/docs/design.md b/docs/design.md index bec76c9..52918b8 100644 --- a/docs/design.md +++ b/docs/design.md @@ -79,7 +79,7 @@ dataset_items( -- master dataset membership (REQ-052 split CHECK(train|val), image_rel, label_rel, added_at) model_versions( - id, project_id → projects, version INT, weights_path, + id, project_id → projects, version INT, name TEXT, weights_path, parent_model_path, metrics TEXT, base_metrics TEXT, created_at, UNIQUE(project_id, version)) @@ -195,6 +195,7 @@ POST /api/projects/{id}/train # → train job (REQ-060,061,062 GET /api/projects/{id}/models # versions + metrics (REQ-063,064) GET /api/models/{id}/weights # download best.pt POST /api/models/{id}/promote # make it the project's base model (REQ-064) +PATCH /api/models/{id}/rename # update model display name { name } GET /api/projects/{id}/live-count/models # weights this project can count with POST /api/projects/{id}/live-count/start # {source|source_rel, model_path, dials} @@ -258,7 +259,8 @@ Frames with no annotations produce an empty `.txt` (REQ-033). **train (REQ-060…065).** Release the SAM3 engine → `YOLO(base/model.pt)` (or pretrained if the project has no base yet) → `.train(data=dataset/data.yaml, **hardware.defaults())` → `evaluate.py` runs `.val()` for both the base model and the new one against the same -`data.yaml` → store `models//best.pt` + `metrics.json`. +`data.yaml` → store `models//best.pt` + `metrics.json`. Each version is auto-named +`{arch}-{labelType}-{epochs}ep-{classNames}-{YYYYMMDD}` (e.g. `yolo11n-bbox-100ep-sack+box-20260909`). `hardware.py` picks defaults from the detected VRAM: diff --git a/docs/requirements.md b/docs/requirements.md index 1208e2a..019f99b 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -336,7 +336,8 @@ changes. delta. - **REQ-064** — Each training run produces a stored model version (weights + metrics). The user can download the weights and **promote that version to be the project's new base - model** for the next round. + model** for the next round. Each version is auto-named + `{arch}-{labelType}-{epochs}ep-{classNames}-{YYYYMMDD}` and can be renamed by the user. - **REQ-065** — SAM3 and training must never hold VRAM at the same time; the system releases the SAM3 model before training starts. diff --git a/docs/tasks.md b/docs/tasks.md index cb09847..5ec2342 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -153,14 +153,14 @@ Serves REQ-060…065. - `backend/hardware.py`: VRAM detection → `batch`/`imgsz`/`device` defaults. - `backend/training.py`: release SAM3, fine-tune from `base/model.pt` on the master dataset, - store `models//`. + store `models//`, auto-name version `{arch}-{labelType}-{epochs}ep-{classNames}-{YYYYMMDD}`. - `backend/evaluate.py`: `.val()` for the base model and the new one against the same `data.yaml`; write `metrics.json`. -- Models page: train button, progress, base-vs-new table, download, *promote*. +- Models page: train button, progress, base-vs-new table, download (`{name}-best.pt`), *promote*. **Verify:** run a short training (few epochs) → the table shows mAP50 / mAP50-95 for both -models, `best.pt` downloads, promoting the version swaps the project's base model and a -second training run starts from it. +models with a descriptive name, `best.pt` downloads as `{name}-best.pt`, promoting the version +swaps the project's base model and a second training run starts from it. Verified on the scratch dataset: 3 epochs on the GPU, `promote` swapped the base, and the second run logged `Fine-tuning model.pt`. The mAP figures are zero because those labels are diff --git a/docs/ui-spec.md b/docs/ui-spec.md index 0ff2d7b..a13e837 100644 --- a/docs/ui-spec.md +++ b/docs/ui-spec.md @@ -685,18 +685,20 @@ only subtract from them. - **Active job card** — `Training Job #id (status)`, `progress/total`, a bar, the error if any, the **last 8 log lines** in a monospace scroll box, a Cancel button while running, and a green "Training Finished" line when done (which also reloads the page data). -- **Trained Model Versions (N)** — one card per version: `v{n}`, the created timestamp, and a - metrics table: +- **Trained Model Versions (N)** — one card per version: the model name (auto-generated + `{arch}-{labelType}-{epochs}ep-{classNames}-{YYYYMMDD}`, clickable to rename inline), + the created timestamp, and a metrics table: | Metric | Base | This version | Δ | |---|---|---|---| | mAP50, mAP50-95, precision, recall | 4 dp | 4 dp | signed, green if >0, red if <0 | When there is no base column, an explicit line says the previous model could not be scored on - this val set. Actions: *Download best.pt*, **Use as base model** (`POST /models/{id}/promote`). + this val set. Actions: *Download best.pt* (`{name}-best.pt`), **Use as base model** + (`POST /models/{id}/promote`). **Endpoints.** `/projects/{id}` , `/dataset`, `/datasets`, `/base-datasets`, `/models`, -`/hardware`, `/train`, `/models/{id}/promote`, `/models/{id}/weights`, `/jobs`. +`/hardware`, `/train`, `/models/{id}/promote`, `/models/{id}/rename`, `/models/{id}/weights`, `/jobs`. --- @@ -1320,6 +1322,7 @@ GET /api/projects/{id}/export?batch_ids=&approved_only=&include_empty= POST /api/projects/{id}/train { epochs, dataset_ids, base_dataset_ids, class_ids } GET /api/projects/{id}/models POST /api/models/{id}/promote +PATCH /api/models/{id}/rename { name } GET /api/models/{id}/weights ``` diff --git a/frontend/src/api.js b/frontend/src/api.js index 8abeace..d839e48 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -229,6 +229,8 @@ export const api = { request(`/projects/${projectId}/train`, { method: 'POST', body }), listModels: (projectId) => request(`/projects/${projectId}/models`), promoteModel: (modelId) => request(`/models/${modelId}/promote`, { method: 'POST' }), + renameModel: (modelId, name) => + request(`/models/${modelId}/rename`, { method: 'PATCH', body: { name } }), weightsUrl: (modelId) => `/api/models/${modelId}/weights`, listJobs: (projectId) => request(`/jobs${projectId ? `?project_id=${projectId}` : ''}`), diff --git a/frontend/src/pages/ModelsPage.jsx b/frontend/src/pages/ModelsPage.jsx index 0106f56..6871413 100644 --- a/frontend/src/pages/ModelsPage.jsx +++ b/frontend/src/pages/ModelsPage.jsx @@ -20,6 +20,8 @@ function Metric({ label, base, next, delta }) { function VersionCard({ version, onPromote, onError }) { const [busy, setBusy] = useState(false) + const [editing, setEditing] = useState(false) + const [nameValue, setNameValue] = useState(version.name || '') const metrics = version.metrics const base = version.base_metrics @@ -35,10 +37,42 @@ function VersionCard({ version, onPromote, onError }) { } } + async function saveName() { + try { + await api.renameModel(version.id, nameValue) + setEditing(false) + onPromote() + } catch (exc) { + onError(exc.message) + } + } + + function handleKeyDown(e) { + if (e.key === 'Enter') saveName() + if (e.key === 'Escape') { setNameValue(version.name || ''); setEditing(false) } + } + return (
-

v{version.version}

+ {editing ? ( + setNameValue(e.target.value)} + onBlur={saveName} + onKeyDown={handleKeyDown} + style={{ fontSize: '1rem', fontFamily: 'inherit', background: '#09090b', border: '1px solid rgba(192,132,252,0.5)', borderRadius: 4, color: '#fff', padding: '2px 6px', flex: 1 }} + /> + ) : ( +

setEditing(true)} + style={{ cursor: 'pointer', margin: 0 }} + > + {version.name || `v${version.version}`} +

+ )} {new Date(version.created_at * 1000).toLocaleString()}