From 2c9c756f0c99d73154eee36a6b2f99cdd7401956 Mon Sep 17 00:00:00 2001 From: dualchimerra Date: Mon, 28 Sep 2026 17:53:08 +0200 Subject: [PATCH] feat: training config redesign, Graph board, UI text and scroll fixes Training config (Train / Presets): - settings render as cards with a grid of inputs and a compact switch list split by a full-width line; related fields grouped per tab (network, dataset, reg set, captions, main params, LR/optimizer, EMA, DOP with the trigger word, system, optimization, output) - new "Finishing" tab with the continue-training fields - model, dataset and output paths hidden while filled automatically - one look for int and float inputs, no "was: ..." notes, no reset-tab - white body with gray cards; pill-style active tabs app-wide UI text: no middle-dot separators in ru strings and components, long explanations moved behind a "?" help tip, Dropout renamed in ru. Perf: live progress bars no longer animate width (forced a full layout every frame); scroll panes scroll on the compositor. Graph: experiment board (API, store, web views) with tests. Co-Authored-By: Claude Opus 5.5 --- studio/api/app.py | 2 + studio/api/routers/graph.py | 252 +++++ studio/services/graph/__init__.py | 1 + studio/services/graph/defaults.py | 152 +++ studio/services/graph/store.py | 623 +++++++++++++ studio/services/graph/tasks.py | 162 ++++ studio/web/src/App.tsx | 2 + studio/web/src/components/BaseModelSelect.tsx | 2 +- studio/web/src/components/CommandPalette.tsx | 5 +- .../src/components/DuplicateReviewPanel.tsx | 2 +- studio/web/src/components/Field.tsx | 109 ++- studio/web/src/components/InfoButton.tsx | 87 +- studio/web/src/components/JobProgress.tsx | 2 +- .../web/src/components/LLMTaggerWorkspace.tsx | 4 +- .../src/components/ModelsRootMigrateModal.tsx | 2 +- .../web/src/components/MonitorDashboard.tsx | 21 +- .../web/src/components/PauseProgressModal.tsx | 2 +- studio/web/src/components/RuntimeModeGate.tsx | 2 +- studio/web/src/components/SchemaForm.tsx | 117 ++- studio/web/src/components/Sidebar.tsx | 2 + .../src/components/StudioDataMigrateModal.tsx | 2 +- studio/web/src/components/SystemStats.tsx | 6 +- studio/web/src/components/TagStatsPanel.tsx | 2 +- studio/web/src/components/TaskSampleStrip.tsx | 6 +- studio/web/src/components/Topbar.tsx | 13 +- studio/web/src/components/TriggerWordCard.tsx | 84 +- .../web/src/components/UploadProgressBar.tsx | 3 - .../web/src/components/VersionStatusBadge.tsx | 2 +- studio/web/src/components/ds/HelpTip.tsx | 81 ++ .../components/preprocess/FreeCropEditor.tsx | 2 +- .../preprocess/PreprocessJobStrip.tsx | 2 +- .../preprocess/PreprocessToolsBar.tsx | 2 +- studio/web/src/i18n/locales/en.json | 369 +++++++- studio/web/src/i18n/locales/ru.json | 863 +++++++++++++----- studio/web/src/index.css | 1 + studio/web/src/pages/Projects.tsx | 9 +- studio/web/src/pages/Queue.tsx | 9 +- studio/web/src/pages/QueueDetail.tsx | 18 +- studio/web/src/pages/project/Overview.tsx | 20 +- .../web/src/pages/project/steps/Curation.tsx | 7 +- .../web/src/pages/project/steps/Download.tsx | 8 +- .../src/pages/project/steps/Preprocess.tsx | 4 +- .../pages/project/steps/PreprocessCrop.tsx | 6 +- .../project/steps/PreprocessDuplicates.tsx | 5 +- .../pages/project/steps/PreprocessInpaint.tsx | 6 +- .../project/steps/PreprocessOverview.tsx | 6 +- .../pages/project/steps/Regularization.tsx | 4 +- .../web/src/pages/project/steps/TagEdit.tsx | 6 +- studio/web/src/pages/project/steps/Train.tsx | 80 +- studio/web/src/pages/queue/DataJobsPanel.tsx | 2 +- studio/web/src/pages/tools/Generate.tsx | 7 +- studio/web/src/pages/tools/Graph.test.tsx | 115 +++ studio/web/src/pages/tools/Graph.tsx | 674 ++++++++++++++ studio/web/src/pages/tools/Monitor.tsx | 7 +- studio/web/src/pages/tools/Presets.tsx | 10 +- studio/web/src/pages/tools/Settings.tsx | 15 +- studio/web/src/pages/tools/Soup.tsx | 9 +- .../src/pages/tools/generate/AspectChips.tsx | 2 +- .../pages/tools/generate/DaemonControls.tsx | 2 +- .../pages/tools/generate/GenerateProgress.tsx | 4 +- .../pages/tools/generate/PreviewCompare.tsx | 2 +- .../tools/generate/PreviewHistoryRail.tsx | 6 +- .../pages/tools/generate/PreviewXYGrid.tsx | 6 +- .../pages/tools/generate/SidebarXYAxes.tsx | 2 +- .../web/src/pages/tools/graph/CardsView.tsx | 295 ++++++ .../web/src/pages/tools/graph/CompareView.tsx | 292 ++++++ .../tools/graph/CreateTrainingDialog.tsx | 217 +++++ .../web/src/pages/tools/graph/FilterPanel.tsx | 187 ++++ .../web/src/pages/tools/graph/ImageDialog.tsx | 116 +++ .../src/pages/tools/graph/ImportDialogs.tsx | 143 +++ .../web/src/pages/tools/graph/MatrixView.tsx | 168 ++++ .../src/pages/tools/graph/ParamsManager.tsx | 459 ++++++++++ .../web/src/pages/tools/graph/RunDrawer.tsx | 377 ++++++++ .../web/src/pages/tools/graph/TableView.tsx | 100 ++ studio/web/src/pages/tools/graph/WebView.tsx | 275 ++++++ studio/web/src/pages/tools/graph/api.ts | 65 ++ .../web/src/pages/tools/graph/model.test.ts | 153 ++++ studio/web/src/pages/tools/graph/model.ts | 609 ++++++++++++ studio/web/src/pages/tools/graph/types.ts | 168 ++++ studio/web/src/pages/tools/graph/ui.tsx | 203 ++++ studio/web/src/pages/tools/graph/useGraph.ts | 250 +++++ studio/web/src/styles/ds.css | 90 +- studio/web/src/styles/graph.css | 478 ++++++++++ tests/test_graph.py | 132 +++ 84 files changed, 8189 insertions(+), 628 deletions(-) create mode 100644 studio/api/routers/graph.py create mode 100644 studio/services/graph/__init__.py create mode 100644 studio/services/graph/defaults.py create mode 100644 studio/services/graph/store.py create mode 100644 studio/services/graph/tasks.py create mode 100644 studio/web/src/components/ds/HelpTip.tsx create mode 100644 studio/web/src/pages/tools/Graph.test.tsx create mode 100644 studio/web/src/pages/tools/Graph.tsx create mode 100644 studio/web/src/pages/tools/graph/CardsView.tsx create mode 100644 studio/web/src/pages/tools/graph/CompareView.tsx create mode 100644 studio/web/src/pages/tools/graph/CreateTrainingDialog.tsx create mode 100644 studio/web/src/pages/tools/graph/FilterPanel.tsx create mode 100644 studio/web/src/pages/tools/graph/ImageDialog.tsx create mode 100644 studio/web/src/pages/tools/graph/ImportDialogs.tsx create mode 100644 studio/web/src/pages/tools/graph/MatrixView.tsx create mode 100644 studio/web/src/pages/tools/graph/ParamsManager.tsx create mode 100644 studio/web/src/pages/tools/graph/RunDrawer.tsx create mode 100644 studio/web/src/pages/tools/graph/TableView.tsx create mode 100644 studio/web/src/pages/tools/graph/WebView.tsx create mode 100644 studio/web/src/pages/tools/graph/api.ts create mode 100644 studio/web/src/pages/tools/graph/model.test.ts create mode 100644 studio/web/src/pages/tools/graph/model.ts create mode 100644 studio/web/src/pages/tools/graph/types.ts create mode 100644 studio/web/src/pages/tools/graph/ui.tsx create mode 100644 studio/web/src/pages/tools/graph/useGraph.ts create mode 100644 studio/web/src/styles/graph.css create mode 100644 tests/test_graph.py diff --git a/studio/api/app.py b/studio/api/app.py index 271ba47..db8d12a 100644 --- a/studio/api/app.py +++ b/studio/api/app.py @@ -27,6 +27,7 @@ data_exports, events_sse, generate, + graph, health, installs, logs, @@ -105,6 +106,7 @@ # PR-6 commit 5: generate router (8 routes: image generation + daemon status + TAEFlux) app.include_router(generate.router) app.include_router(soup.router) +app.include_router(graph.router) app.include_router(tunnel.router) # PR-6 commit 6: 3 files in the queue subpackage (lifecycle 12 + io 3 + outputs 5 = 20 routes) # Registration order: io must come before lifecycle (FastAPI matches paths in diff --git a/studio/api/routers/graph.py b/studio/api/routers/graph.py new file mode 100644 index 0000000..2c8de8c --- /dev/null +++ b/studio/api/routers/graph.py @@ -0,0 +1,252 @@ +"""Graph: store and compare LoRA training results. + +Boards + GET /api/graph/boards list + POST /api/graph/boards create (default / empty / copy params) + GET /api/graph/boards/{bid} board + every card and image + PATCH /api/graph/boards/{bid} name / note / params / views + DELETE /api/graph/boards/{bid} + POST /api/graph/boards/{bid}/remap rewrite one parameter's value across cards +Cards (runs) + POST /api/graph/boards/{bid}/runs + PATCH /api/graph/runs/{rid} + POST /api/graph/runs/{rid}/duplicate copy settings, never images + DELETE /api/graph/runs/{rid} + GET /api/graph/runs/{rid}/config training config as .yaml (task snapshot → version) +Images + POST /api/graph/runs/{rid}/images multipart upload (PNG / JPEG / WebP) + POST /api/graph/runs/{rid}/import-samples copy samples of a queue task into the card + PATCH /api/graph/images/{iid} + DELETE /api/graph/images/{iid} + GET /api/graph/images/{iid}/file original, or ?w=N thumbnail +Queue (read-only) + GET /api/graph/tasks?keys=a,b training tasks + the config keys asked for + GET /api/graph/tasks/{tid}/samples sample files with step / prompt / seed +""" +from __future__ import annotations + +import json +import logging +from typing import Any, Optional + +import yaml +from fastapi import APIRouter, File, Form, UploadFile +from fastapi.responses import FileResponse, Response + +from ...domain.errors import NotFoundError, ValidationError +from ...services.graph import store, tasks as graph_tasks +from ..responses import _thumb_response + +router = APIRouter() +logger = logging.getLogger(__name__) + + +def _call(fn, *args, **kwargs): + try: + return fn(*args, **kwargs) + except store.GraphNotFound as exc: + raise NotFoundError(str(exc), code="graph.not_found") from exc + except store.GraphError as exc: + raise ValidationError(str(exc), code="graph.invalid", + details={"reason": str(exc)}) from exc + + +# ── boards ────────────────────────────────────────────────────────────────── + +@router.get("/api/graph/boards") +def list_boards() -> dict[str, Any]: + return {"items": _call(store.list_boards)} + + +@router.post("/api/graph/boards") +def create_board(body: dict[str, Any]) -> dict[str, Any]: + src = body.get("copy_params_from") + return _call(store.create_board, body.get("name", ""), + copy_params_from=int(src) if src not in (None, "") else None, + empty=bool(body.get("empty"))) + + +@router.get("/api/graph/boards/{bid}") +def get_board(bid: int) -> dict[str, Any]: + return _call(store.get_board, bid) + + +@router.patch("/api/graph/boards/{bid}") +def update_board(bid: int, body: dict[str, Any]) -> dict[str, Any]: + return _call(store.update_board, bid, body) + + +@router.delete("/api/graph/boards/{bid}") +def delete_board(bid: int) -> dict[str, Any]: + _call(store.delete_board, bid) + return {"ok": True} + + +@router.post("/api/graph/boards/{bid}/remap") +def remap(bid: int, body: dict[str, Any]) -> dict[str, Any]: + mapping = body.get("mapping") + if not isinstance(mapping, list): + raise ValidationError("mapping must be a list") + return {"changed": _call(store.remap_values, bid, str(body.get("param_id", "")), mapping)} + + +# ── runs ──────────────────────────────────────────────────────────────────── + +@router.post("/api/graph/boards/{bid}/runs") +def create_run(bid: int, body: dict[str, Any]) -> dict[str, Any]: + return _call(store.create_run, bid, body) + + +@router.patch("/api/graph/runs/{rid}") +def update_run(rid: int, body: dict[str, Any]) -> dict[str, Any]: + return _call(store.update_run, rid, body) + + +@router.post("/api/graph/runs/{rid}/duplicate") +def duplicate_run(rid: int, body: Optional[dict[str, Any]] = None) -> dict[str, Any]: + return _call(store.duplicate_run, rid, (body or {}).get("overrides")) + + +@router.delete("/api/graph/runs/{rid}") +def delete_run(rid: int) -> dict[str, Any]: + _call(store.delete_run, rid) + return {"ok": True} + + +@router.get("/api/graph/runs/{rid}/config") +def run_config(rid: int) -> Response: + """The config the card's training actually used. + + Prefers the linked task's frozen snapshot (exactly what ran); falls back + to the linked version's current config. Cards without either get a 404 — + the UI then offers a fragment built from the card's own parameters. + """ + run = _call(store.get_run, rid) + text: Optional[str] = None + if run.get("task_id"): + row = graph_tasks.task_row(int(run["task_id"])) + if row: + cfg = graph_tasks.task_config(int(run["task_id"]), row["config_path"]) + if cfg: + text = yaml.safe_dump(cfg, allow_unicode=True, sort_keys=False) + if text is None and run.get("project_id") and run.get("version_id"): + from ...services import version_config + from ...services.projects import projects, versions + from ... import db + + with db.connection_for() as conn: + p = projects.get_project(conn, int(run["project_id"])) + v = versions.get_version(conn, int(run["version_id"])) + if p and v and version_config.has_version_config(p, v): + try: + cfg = version_config.read_version_config(p, v) + text = yaml.safe_dump(cfg, allow_unicode=True, sort_keys=False) + except version_config.VersionConfigError: + text = None + if text is None: + raise NotFoundError("This card has no linked training config", code="graph.no_config") + safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in (run.get("name") or f"run_{rid}")) + return Response( + content=text, media_type="application/x-yaml", + headers={"Content-Disposition": f'attachment; filename="{safe or "config"}.yaml"'}, + ) + + +# ── images ────────────────────────────────────────────────────────────────── + +@router.post("/api/graph/runs/{rid}/images") +async def upload_images( + rid: int, + files: list[UploadFile] = File(...), + meta: str = Form(""), +) -> dict[str, Any]: + """``meta`` is an optional JSON object applied to every uploaded file + (e.g. the step the user picked before dropping).""" + try: + common = json.loads(meta) if meta else {} + except ValueError: + common = {} + if not isinstance(common, dict): + common = {} + added: list[dict[str, Any]] = [] + errors: list[dict[str, str]] = [] + for f in files: + data = await f.read() + try: + added.append(_call(store.add_image, rid, data, {**common, "source": f.filename or ""})) + except ValidationError as exc: + errors.append({"name": f.filename or "", "reason": exc.message}) + if not added and errors: + raise ValidationError(errors[0]["reason"], code="graph.invalid", details={"errors": errors}) + return {"items": added, "errors": errors} + + +@router.post("/api/graph/runs/{rid}/import-samples") +def import_samples(rid: int, body: dict[str, Any]) -> dict[str, Any]: + task_id = int(body.get("task_id") or 0) + names = [str(n) for n in (body.get("filenames") or [])] + if not task_id or not names: + raise ValidationError("Pick a task and at least one sample") + row = graph_tasks.task_row(task_id) + if not row: + raise NotFoundError("Task not found", code="graph.task_not_found") + cfg = graph_tasks.task_config(task_id, row["config_path"]) or {} + info = store.describe_samples(names, cfg, graph_tasks.monitor_summary(task_id)) + existing = {i["source"] for i in _call(store.get_run, rid)["images"]} + added: list[dict[str, Any]] = [] + skipped = 0 + for n in names: + source = f"task:{task_id}/{n}" + if source in existing: + skipped += 1 + continue + p = graph_tasks.sample_file(task_id, n) + if p is None: + skipped += 1 + continue + meta = dict(info.get(n) or {}) + epoch = meta.pop("epoch", None) + if epoch is not None: + meta["comment"] = f"epoch {epoch}" + meta["source"] = source + added.append(_call(store.add_image, rid, p.read_bytes(), meta)) + return {"items": added, "skipped": skipped} + + +@router.patch("/api/graph/images/{iid}") +def update_image(iid: int, body: dict[str, Any]) -> dict[str, Any]: + return _call(store.update_image, iid, body) + + +@router.delete("/api/graph/images/{iid}") +def delete_image(iid: int) -> dict[str, Any]: + _call(store.delete_image, iid) + return {"ok": True} + + +@router.get("/api/graph/images/{iid}/file") +def image_file(iid: int, w: Optional[int] = None) -> FileResponse: + p = _call(store.image_path, iid) + # Stored files are write-once (uuid names), so the URL is a stable key. + return _thumb_response(p, w if (w and w > 0) else 0, immutable=True) + + +# ── queue bridge ──────────────────────────────────────────────────────────── + +@router.get("/api/graph/tasks") +def list_tasks(keys: str = "") -> dict[str, Any]: + wanted = [k for k in (s.strip() for s in keys.split(",")) if k][:200] + return {"items": graph_tasks.list_train_tasks(wanted)} + + +@router.get("/api/graph/tasks/{tid}/samples") +def task_samples(tid: int) -> dict[str, Any]: + from .samples import list_task_samples + + listing = list_task_samples(tid) + row = graph_tasks.task_row(tid) + cfg = (graph_tasks.task_config(tid, row["config_path"]) if row else None) or {} + listed = [i for i in listing.get("items", []) if store.is_sample_name(i["filename"])] + info = store.describe_samples([i["filename"] for i in listed], cfg, graph_tasks.monitor_summary(tid)) + items = [{**i, **(info.get(i["filename"]) or {})} for i in listed] + return {"items": items, "total": len(items)} diff --git a/studio/services/graph/__init__.py b/studio/services/graph/__init__.py new file mode 100644 index 0000000..f234e77 --- /dev/null +++ b/studio/services/graph/__init__.py @@ -0,0 +1 @@ +"""Graph: store and compare LoRA training results (see store.py).""" diff --git a/studio/services/graph/defaults.py b/studio/services/graph/defaults.py new file mode 100644 index 0000000..8b24c7e --- /dev/null +++ b/studio/services/graph/defaults.py @@ -0,0 +1,152 @@ +"""Starting parameter set for a new Graph board. + +Every value here is only a suggestion: the user can rename, reorder, hide, +archive or delete any parameter or option, and add their own. The ``config`` +block on an option (or on a number parameter) ties it to a training-config +key, which is what lets the Graph + +* recognise queue tasks whose frozen config matches a card or an empty slot, +* build a new version's config from a slot ("create training from here"). + +``apply`` is the patch written into the version config; ``match`` is how a +task config is recognised (defaults to ``apply``). ``match`` values may be a +literal or one operator object: ``{"$gt": n}``, ``{"$gte": n}``, +``{"$truthy": bool}``. +""" +from __future__ import annotations + +from typing import Any + + +def _opt(oid: str, label: str, apply: dict[str, Any] | None = None, + match: dict[str, Any] | None = None) -> dict[str, Any]: + out: dict[str, Any] = {"id": oid, "label": label} + if apply is not None or match is not None: + out["config"] = {"apply": apply or {}, **({"match": match} if match is not None else {})} + return out + + +def _num(oid: str, value: float) -> dict[str, Any]: + return {"id": oid, "value": value} + + +def default_params() -> list[dict[str, Any]]: + return [ + { + "id": "model", "name": "Model", "type": "list", + "options": [ + _opt("anima", "Anima", {"model_family": "anima"}), + _opt("krea2", "Krea2", {"model_family": "krea2"}), + ], + }, + { + "id": "resolution", "name": "Resolution", "type": "number", + "config": {"key": "resolution", "list": True}, + "options": [_num("r1024", 1024), _num("r1536", 1536)], + }, + { + "id": "reg", "name": "Regulation set", "type": "bool", + "description": "Whether the version trains with a regularization set. " + "It comes from the version's data, so creating a training " + "from the Graph can't switch it.", + "options": [ + _opt("yes", "Yes", {}, {"reg_data_dir": {"$truthy": True}}), + _opt("no", "No", {}, {"reg_data_dir": {"$truthy": False}}), + ], + }, + { + "id": "navit", "name": "NaViT packing", "type": "bool", + "options": [ + _opt("yes", "Yes", {"navit_packing": True}), + _opt("no", "No", {"navit_packing": False}), + ], + }, + { + "id": "lora_type", "name": "LoRA type", "type": "list", + "options": [ + _opt("tlora", "TLoRA (Ortho)", {"lora_type": "tlora", "tlora_use_ortho": True}), + _opt("lora", "LoRA", {"lora_type": "lora"}), + _opt("lokr", "LoKr", {"lora_type": "lokr"}), + ], + }, + { + "id": "rank_alpha", "name": "Rank/Alpha", "type": "list", + "description": "Full matrix = LoKr with a huge dim, so LyCORIS stops " + "decomposing the second block.", + "options": [ + _opt("full", "Full matrix", {"lora_rank": 10000000000, "lora_alpha": 10000000000}, + {"lora_rank": {"$gte": 10000}}), + _opt("r32a32", "32/32", {"lora_rank": 32, "lora_alpha": 32}), + _opt("r64a64", "64/64", {"lora_rank": 64, "lora_alpha": 64}), + _opt("r48a32", "48/32", {"lora_rank": 48, "lora_alpha": 32}), + ], + }, + { + "id": "dora", "name": "DoRA", "type": "bool", + "options": [ + _opt("yes", "Yes", {"lora_dora": True}), + _opt("no", "No", {"lora_dora": False}), + ], + }, + { + "id": "rslora", "name": "RS LoRA", "type": "bool", + "options": [ + _opt("yes", "Yes", {"lora_rs": True}), + _opt("no", "No", {"lora_rs": False}), + ], + }, + { + "id": "steps", "name": "Total steps", "type": "number", + "config": {"key": "max_steps"}, + "options": [_num("s1500", 1500), _num("s2000", 2000), _num("s2500", 2500), _num("s3000", 3000)], + }, + { + "id": "lr", "name": "LR", "type": "number", + "config": {"key": "learning_rate"}, + "options": [ + _num("lr1", 0.0001), _num("lr2", 0.00001), _num("lr3", 0.00002), + _num("lr4", 0.00003), _num("lr5", 0.00005), + ], + }, + { + "id": "scheduler", "name": "Scheduler", "type": "list", + "options": [ + _opt("constant", "Constant", {"lr_scheduler": "none"}), + _opt("cosine", "Cosine", {"lr_scheduler": "cosine"}), + ], + }, + { + "id": "optimizer", "name": "Optimizer", "type": "list", + "options": [ + _opt("adamw", "AdamW", {"optimizer_type": "adamw"}), + _opt("came", "CAME", {"optimizer_type": "came"}), + _opt("prodigy", "Prodigy+", {"optimizer_type": "prodigy_plus_schedulefree"}), + ], + }, + { + "id": "wd", "name": "Weight decay", "type": "bool", + "options": [ + _opt("yes", "Yes", {"weight_decay": 0.01}, {"weight_decay": {"$gt": 0}}), + _opt("no", "No", {"weight_decay": 0}), + ], + }, + { + "id": "sampling", "name": "Sampling", "type": "list", + "options": [ + _opt("style", "Style-friendly", {"timestep_sampling": "style_friendly"}), + _opt("logit", "Logit-normal", {"timestep_sampling": "logit_normal"}), + ], + }, + { + "id": "snr_mean", "name": "SNR mean", "type": "number", + "config": {"key": "style_snr_mean"}, + "condition": {"param": "sampling", "options": ["style"]}, + "options": [], + }, + { + "id": "snr_sigma", "name": "SNR sigma", "type": "number", + "config": {"key": "style_snr_sigma"}, + "condition": {"param": "sampling", "options": ["style"]}, + "options": [], + }, + ] diff --git a/studio/services/graph/store.py b/studio/services/graph/store.py new file mode 100644 index 0000000..7927728 --- /dev/null +++ b/studio/services/graph/store.py @@ -0,0 +1,623 @@ +"""Graph: a place to keep and compare LoRA training results. + +A *board* holds its own parameter definitions (``params``), saved views and +the *runs* (one card = one training run). Each run can carry sample images, +copied into ``studio_data/graph/images/`` so moving or deleting the original +file — or deleting the task / project it came from — never breaks the card. + +Storage is a dedicated SQLite file (``studio_data/graph/graph.db``) rather +than tables in ``studio.db``: the Graph is a self-contained tool and never +joins against the queue tables, so it has no reason to take part in the main +schema's migration chain. + +Board ``params`` and run ``values`` are JSON the frontend owns; this module +only checks their shape loosely. Runs are never hard-deleted as a side effect +of anything outside the Graph. +""" +from __future__ import annotations + +import io +import json +import logging +import re +import shutil +import sqlite3 +import time +import uuid +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Iterator, Optional + +from ...infrastructure import paths as _paths +from ...infrastructure.db import connect +from .defaults import default_params + +logger = logging.getLogger(__name__) + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS boards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + data TEXT NOT NULL DEFAULT '{}', + created_at REAL NOT NULL, + updated_at REAL NOT NULL +); +CREATE TABLE IF NOT EXISTS runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE, + data TEXT NOT NULL DEFAULT '{}', + created_at REAL NOT NULL, + updated_at REAL NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_runs_board ON runs(board_id); +CREATE TABLE IF NOT EXISTS images ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE, + board_id INTEGER NOT NULL, + filename TEXT NOT NULL, + width INTEGER, + height INTEGER, + step INTEGER, + prompt TEXT, + seed INTEGER, + comment TEXT, + rating INTEGER, + source TEXT, + sort INTEGER NOT NULL DEFAULT 0, + created_at REAL NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_images_run ON images(run_id); +""" + +#: Run fields the API accepts; anything else in a request body is dropped. +RUN_FIELDS = ( + "name", "values", "status", "notes", "link", "favorite", "rating", + "best_step", "tags", "task_id", "project_id", "version_id", +) +RUN_STATUSES = ("planned", "training", "done", "stopped") +IMAGE_FIELDS = ("step", "prompt", "seed", "comment", "rating", "sort") + +_EXT_BY_FORMAT = {"PNG": ".png", "JPEG": ".jpg", "WEBP": ".webp"} +MAX_IMAGE_BYTES = 64 * 1024 ** 2 +#: The one preview size the Graph UI asks for (web/src/pages/tools/graph/ui.tsx THUMB_PX). +THUMB_PX = 384 + + +class GraphError(Exception): + """Something the user can fix (bad value, unsupported file…).""" + + +class GraphNotFound(GraphError): + pass + + +# ── paths / connection ────────────────────────────────────────────────────── + +def graph_root() -> Path: + # Read through the module so tests that repoint STUDIO_DATA are honoured. + return Path(_paths.STUDIO_DATA) / "graph" + + +def db_path() -> Path: + return graph_root() / "graph.db" + + +def images_dir(board_id: int, run_id: int) -> Path: + return graph_root() / "images" / str(int(board_id)) / str(int(run_id)) + + +#: Database files whose schema was already ensured in this process. +_READY: set[str] = set() + + +@contextmanager +def conn_ctx() -> Iterator[sqlite3.Connection]: + path = db_path() + conn = connect(path) + try: + # CREATE ... IF NOT EXISTS takes a write lock; do it once per file, not + # on every request (image previews load dozens at a time). + if str(path) not in _READY or not path.exists(): + conn.executescript(SCHEMA) + _READY.add(str(path)) + yield conn + finally: + conn.close() + + +# ── serialisation ─────────────────────────────────────────────────────────── + +def _loads(raw: Optional[str]) -> dict[str, Any]: + try: + val = json.loads(raw or "{}") + except ValueError: + return {} + return val if isinstance(val, dict) else {} + + +def _image_row(row: sqlite3.Row) -> dict[str, Any]: + return { + "id": row["id"], + "run_id": row["run_id"], + "width": row["width"], + "height": row["height"], + "step": row["step"], + "prompt": row["prompt"] or "", + "seed": row["seed"], + "comment": row["comment"] or "", + "rating": row["rating"], + "source": row["source"] or "", + "sort": row["sort"], + "created_at": row["created_at"], + } + + +def _run_row(row: sqlite3.Row, images: list[dict[str, Any]]) -> dict[str, Any]: + data = _loads(row["data"]) + out = {k: data.get(k) for k in RUN_FIELDS} + out["values"] = out["values"] if isinstance(out["values"], dict) else {} + out["tags"] = out["tags"] if isinstance(out["tags"], list) else [] + out["status"] = out["status"] if out["status"] in RUN_STATUSES else "planned" + out["favorite"] = bool(out["favorite"]) + out["name"] = out["name"] or "" + out["notes"] = out["notes"] or "" + out["link"] = out["link"] or "" + out.update({ + "id": row["id"], + "board_id": row["board_id"], + "created_at": row["created_at"], + "updated_at": row["updated_at"], + "images": images, + }) + return out + + +def _board_row(row: sqlite3.Row) -> dict[str, Any]: + data = _loads(row["data"]) + return { + "id": row["id"], + "name": row["name"], + "note": data.get("note") or "", + "params": data.get("params") if isinstance(data.get("params"), list) else [], + "views": data.get("views") if isinstance(data.get("views"), list) else [], + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + + +# ── validation helpers ────────────────────────────────────────────────────── + +def _clean_name(name: Any, what: str) -> str: + s = str(name or "").strip() + if not s: + raise GraphError(f"{what} name can't be empty") + return s[:200] + + +def _clean_run(patch: dict[str, Any]) -> dict[str, Any]: + out: dict[str, Any] = {} + for k in RUN_FIELDS: + if k not in patch: + continue + v = patch[k] + if k == "values": + if not isinstance(v, dict): + raise GraphError("values must be an object") + # Unset = key absent; never store null so "not set" can't be + # confused with a value. + v = {str(pk): pv for pk, pv in v.items() + if pv is not None and isinstance(pv, (str, int, float, bool))} + elif k == "status": + if v not in RUN_STATUSES: + raise GraphError(f"Unknown status: {v}") + elif k == "tags": + v = [str(t).strip()[:60] for t in (v or []) if str(t).strip()][:30] + elif k == "rating": + v = None if v in (None, 0, "") else max(1, min(3, int(v))) + elif k in ("task_id", "project_id", "version_id", "best_step"): + v = None if v in (None, "") else int(v) + elif k == "favorite": + v = bool(v) + elif k in ("name", "notes", "link"): + v = str(v or "")[: (20000 if k == "notes" else 1000)] + out[k] = v + return out + + +# ── boards ────────────────────────────────────────────────────────────────── + +def list_boards() -> list[dict[str, Any]]: + with conn_ctx() as conn: + rows = conn.execute( + "SELECT b.*, " + "(SELECT COUNT(*) FROM runs r WHERE r.board_id = b.id) AS run_count, " + "(SELECT COUNT(*) FROM images i WHERE i.board_id = b.id) AS image_count " + "FROM boards b ORDER BY b.updated_at DESC" + ).fetchall() + return [ + {"id": r["id"], "name": r["name"], "note": _loads(r["data"]).get("note") or "", + "run_count": r["run_count"], "image_count": r["image_count"], + "created_at": r["created_at"], "updated_at": r["updated_at"]} + for r in rows + ] + + +def create_board(name: str, *, copy_params_from: Optional[int] = None, + empty: bool = False) -> dict[str, Any]: + name = _clean_name(name, "Board") + if copy_params_from is not None: + params = get_board(copy_params_from)["board"]["params"] + else: + params = [] if empty else default_params() + now = time.time() + with conn_ctx() as conn: + cur = conn.execute( + "INSERT INTO boards(name, data, created_at, updated_at) VALUES (?, ?, ?, ?)", + (name, json.dumps({"params": params, "views": []}), now, now), + ) + conn.commit() + bid = int(cur.lastrowid) + return get_board(bid) + + +def _must_board(conn: sqlite3.Connection, bid: int) -> sqlite3.Row: + row = conn.execute("SELECT * FROM boards WHERE id = ?", (bid,)).fetchone() + if not row: + raise GraphNotFound("Board not found") + return row + + +def get_board(bid: int) -> dict[str, Any]: + with conn_ctx() as conn: + board = _board_row(_must_board(conn, bid)) + run_rows = conn.execute( + "SELECT * FROM runs WHERE board_id = ? ORDER BY created_at ASC", (bid,) + ).fetchall() + img_rows = conn.execute( + "SELECT * FROM images WHERE board_id = ? ORDER BY sort ASC, id ASC", (bid,) + ).fetchall() + by_run: dict[int, list[dict[str, Any]]] = {} + for r in img_rows: + by_run.setdefault(r["run_id"], []).append(_image_row(r)) + return {"board": board, "runs": [_run_row(r, by_run.get(r["id"], [])) for r in run_rows]} + + +def update_board(bid: int, patch: dict[str, Any]) -> dict[str, Any]: + with conn_ctx() as conn: + row = _must_board(conn, bid) + data = _loads(row["data"]) + name = row["name"] + if "name" in patch: + name = _clean_name(patch["name"], "Board") + if "note" in patch: + data["note"] = str(patch["note"] or "")[:5000] + if "params" in patch: + if not isinstance(patch["params"], list): + raise GraphError("params must be a list") + ids = [p.get("id") for p in patch["params"] if isinstance(p, dict)] + if len(ids) != len(patch["params"]) or len(set(ids)) != len(ids) or not all(ids): + raise GraphError("Every parameter needs a unique id") + data["params"] = patch["params"] + if "views" in patch: + if not isinstance(patch["views"], list): + raise GraphError("views must be a list") + data["views"] = patch["views"][:100] + conn.execute( + "UPDATE boards SET name = ?, data = ?, updated_at = ? WHERE id = ?", + (name, json.dumps(data), time.time(), bid), + ) + conn.commit() + return _board_row(_must_board(conn, bid)) + + +def delete_board(bid: int) -> None: + with conn_ctx() as conn: + _must_board(conn, bid) + conn.execute("DELETE FROM images WHERE board_id = ?", (bid,)) + conn.execute("DELETE FROM runs WHERE board_id = ?", (bid,)) + conn.execute("DELETE FROM boards WHERE id = ?", (bid,)) + conn.commit() + shutil.rmtree(graph_root() / "images" / str(int(bid)), ignore_errors=True) + + +def remap_values(bid: int, param_id: str, mapping: list[dict[str, Any]]) -> int: + """Rewrite one parameter's value across every run of a board. + + ``mapping`` is ``[{"from": old, "to": new_or_null}]``; ``to: null`` unsets + the value. Numbers compare with a relative tolerance so ``1e-4`` and + ``0.0001`` are the same value. Returns how many runs changed. + """ + changed = 0 + with conn_ctx() as conn: + _must_board(conn, bid) + rows = conn.execute("SELECT id, data FROM runs WHERE board_id = ?", (bid,)).fetchall() + now = time.time() + for r in rows: + data = _loads(r["data"]) + values = data.get("values") if isinstance(data.get("values"), dict) else {} + if param_id not in values: + continue + cur = values[param_id] + for m in mapping: + if same_value(cur, m.get("from")): + if m.get("to") is None: + values.pop(param_id, None) + else: + values[param_id] = m["to"] + data["values"] = values + conn.execute("UPDATE runs SET data = ?, updated_at = ? WHERE id = ?", + (json.dumps(data), now, r["id"])) + changed += 1 + break + conn.commit() + return changed + + +def same_value(a: Any, b: Any) -> bool: + if isinstance(a, bool) or isinstance(b, bool): + return a == b + if isinstance(a, (int, float)) and isinstance(b, (int, float)): + if a == b: + return True + return abs(a - b) <= 1e-9 * max(abs(a), abs(b)) + return a == b + + +# ── runs ──────────────────────────────────────────────────────────────────── + +def _must_run(conn: sqlite3.Connection, rid: int) -> sqlite3.Row: + row = conn.execute("SELECT * FROM runs WHERE id = ?", (rid,)).fetchone() + if not row: + raise GraphNotFound("Training card not found") + return row + + +def get_run(rid: int) -> dict[str, Any]: + with conn_ctx() as conn: + row = _must_run(conn, rid) + imgs = conn.execute( + "SELECT * FROM images WHERE run_id = ? ORDER BY sort ASC, id ASC", (rid,) + ).fetchall() + return _run_row(row, [_image_row(i) for i in imgs]) + + +def create_run(bid: int, body: dict[str, Any]) -> dict[str, Any]: + data = {"status": "planned", "values": {}, **_clean_run(body)} + now = time.time() + with conn_ctx() as conn: + _must_board(conn, bid) + cur = conn.execute( + "INSERT INTO runs(board_id, data, created_at, updated_at) VALUES (?, ?, ?, ?)", + (bid, json.dumps(data), now, now), + ) + conn.execute("UPDATE boards SET updated_at = ? WHERE id = ?", (now, bid)) + conn.commit() + rid = int(cur.lastrowid) + return get_run(rid) + + +def update_run(rid: int, patch: dict[str, Any]) -> dict[str, Any]: + clean = _clean_run(patch) + with conn_ctx() as conn: + row = _must_run(conn, rid) + data = _loads(row["data"]) + data.update(clean) + now = time.time() + conn.execute("UPDATE runs SET data = ?, updated_at = ? WHERE id = ?", + (json.dumps(data), now, rid)) + conn.execute("UPDATE boards SET updated_at = ? WHERE id = ?", (now, row["board_id"])) + conn.commit() + return get_run(rid) + + +def duplicate_run(rid: int, overrides: Optional[dict[str, Any]] = None) -> dict[str, Any]: + """Copy a card's settings into a new planned card. Images are *not* copied + (they belong to the run that produced them), nor are the task links.""" + src = get_run(rid) + body = {k: src[k] for k in ("name", "values", "notes", "link", "tags")} + body["name"] = f"{src['name']} (copy)" if src["name"] else "" + body["status"] = "planned" + if overrides: + body.update(overrides) + return create_run(src["board_id"], body) + + +def delete_run(rid: int) -> None: + with conn_ctx() as conn: + row = _must_run(conn, rid) + conn.execute("DELETE FROM images WHERE run_id = ?", (rid,)) + conn.execute("DELETE FROM runs WHERE id = ?", (rid,)) + conn.commit() + shutil.rmtree(images_dir(row["board_id"], rid), ignore_errors=True) + + +# ── images ────────────────────────────────────────────────────────────────── + +def _sniff(data: bytes) -> tuple[str, int, int]: + """Validate an upload and return (extension, width, height).""" + from PIL import Image + + if len(data) > MAX_IMAGE_BYTES: + raise GraphError("The image is larger than 64 MB") + try: + with Image.open(io.BytesIO(data)) as im: + fmt = (im.format or "").upper() + w, h = im.size + except Exception as exc: # noqa: BLE001 - PIL raises many types + raise GraphError("Not a readable image") from exc + ext = _EXT_BY_FORMAT.get(fmt) + if not ext: + raise GraphError("Only PNG, JPEG and WebP images are supported") + return ext, int(w), int(h) + + +def _next_sort(conn: sqlite3.Connection, rid: int) -> int: + row = conn.execute("SELECT COALESCE(MAX(sort), -1) + 1 FROM images WHERE run_id = ?", + (rid,)).fetchone() + return int(row[0]) + + +def add_image(rid: int, data: bytes, meta: Optional[dict[str, Any]] = None) -> dict[str, Any]: + ext, w, h = _sniff(data) + meta = meta or {} + with conn_ctx() as conn: + run = _must_run(conn, rid) + d = images_dir(run["board_id"], rid) + d.mkdir(parents=True, exist_ok=True) + fname = f"{uuid.uuid4().hex}{ext}" + (d / fname).write_bytes(data) + _warm_thumb(d / fname) + step = meta.get("step") + seed = meta.get("seed") + cur = conn.execute( + "INSERT INTO images(run_id, board_id, filename, width, height, step, prompt, " + "seed, comment, source, sort, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + (rid, run["board_id"], fname, w, h, + None if step in (None, "") else int(step), + str(meta.get("prompt") or "")[:4000], + None if seed in (None, "") else int(seed), + str(meta.get("comment") or "")[:4000], + str(meta.get("source") or "")[:500], + _next_sort(conn, rid), time.time()), + ) + conn.execute("UPDATE runs SET updated_at = ? WHERE id = ?", (time.time(), rid)) + conn.commit() + row = conn.execute("SELECT * FROM images WHERE id = ?", (cur.lastrowid,)).fetchone() + return _image_row(row) + + +def _warm_thumb(path: Path) -> None: + """Make the preview now, so the board never waits on a resize.""" + try: + from ..dataset import thumb_cache + + thumb_cache.get_or_make_thumb(path, THUMB_PX) + except Exception: # noqa: BLE001 - a missing preview is made on first view instead + logger.debug("graph: could not pre-make thumbnail for %s", path, exc_info=True) + + +def _must_image(conn: sqlite3.Connection, iid: int) -> sqlite3.Row: + row = conn.execute("SELECT * FROM images WHERE id = ?", (iid,)).fetchone() + if not row: + raise GraphNotFound("Image not found") + return row + + +def update_image(iid: int, patch: dict[str, Any]) -> dict[str, Any]: + sets: list[str] = [] + args: list[Any] = [] + for k in IMAGE_FIELDS: + if k not in patch: + continue + v = patch[k] + if k in ("step", "seed", "sort"): + v = None if v in (None, "") else int(v) + elif k == "rating": + v = None if v in (None, 0, "") else max(1, min(3, int(v))) + else: + v = str(v or "")[:4000] + sets.append(f"{k} = ?") + args.append(v) + with conn_ctx() as conn: + _must_image(conn, iid) + if sets: + conn.execute(f"UPDATE images SET {', '.join(sets)} WHERE id = ?", (*args, iid)) + conn.commit() + return _image_row(_must_image(conn, iid)) + + +def delete_image(iid: int) -> None: + with conn_ctx() as conn: + row = _must_image(conn, iid) + conn.execute("DELETE FROM images WHERE id = ?", (iid,)) + conn.commit() + try: + (images_dir(row["board_id"], row["run_id"]) / row["filename"]).unlink() + except OSError: + pass + + +def image_path(iid: int) -> Path: + with conn_ctx() as conn: + row = _must_image(conn, iid) + p = images_dir(row["board_id"], row["run_id"]) / row["filename"] + if not p.exists(): + raise GraphNotFound("Image file is missing") + return p + + +# ── task samples → card ───────────────────────────────────────────────────── + +_STEP_RE = re.compile(r"^step_(\d+)(?:_baseline_(\d+))?", re.IGNORECASE) +_EPOCH_RE = re.compile(r"^epoch_(\d+)", re.IGNORECASE) + + +def sample_prompts(config: dict[str, Any]) -> list[str]: + prompts = [str(p) for p in (config.get("sample_prompts") or []) if str(p).strip()] + if not prompts and str(config.get("sample_prompt") or "").strip(): + prompts = [str(config["sample_prompt"])] + return prompts + + +#: Files the trainer writes next to samples that aren't samples. +_NOT_SAMPLES = re.compile(r"vae_roundtrip", re.IGNORECASE) + + +def is_sample_name(name: str) -> bool: + return not _NOT_SAMPLES.search(name) + + +def describe_samples(names: list[str], config: dict[str, Any], + monitor: Optional[dict[str, Any]] = None) -> dict[str, dict[str, Any]]: + """Step / prompt / seed for each sample file of a task. + + Epoch samples (``epoch_N.png``) get their step from the monitor's sample + records, or from total_steps / total_epochs when the record has rolled + off. The trainer rotates through the prompt list one sample at a time + (step and epoch samples share the counter), so the n-th sample in step + order used ``prompts[n % len]``; baseline files carry their prompt index + in the name. + """ + prompts = sample_prompts(config) + seed = config.get("sample_seed") + seed = int(seed) if isinstance(seed, (int, float)) and seed else None + mon = monitor or {} + recorded: dict[str, int] = {} + for rec in mon.get("samples") or []: + if isinstance(rec, dict) and isinstance(rec.get("step"), int) and rec.get("path"): + recorded[Path(str(rec["path"])).name] = rec["step"] + per_epoch: Optional[int] = None + try: + if mon.get("total_steps") and mon.get("total_epochs"): + per_epoch = round(int(mon["total_steps"]) / int(mon["total_epochs"])) + except (TypeError, ValueError, ZeroDivisionError): + per_epoch = None + + out: dict[str, dict[str, Any]] = {} + rotating: list[tuple[float, str]] = [] + for n in names: + m = _STEP_RE.match(n) + if m and m.group(2) is not None: + i = int(m.group(2)) + out[n] = {"step": 0, "prompt": prompts[i] if i < len(prompts) else "", + "seed": (seed + i) if seed is not None else None, "epoch": None} + continue + if m: + step = int(m.group(1)) + out[n] = {"step": step, "prompt": "", "seed": seed, "epoch": None} + rotating.append((step, n)) + continue + e = _EPOCH_RE.match(n) + if e: + epoch = int(e.group(1)) + step = recorded.get(n) + if step is None and per_epoch: + step = epoch * per_epoch + out[n] = {"step": step, "prompt": "", "seed": seed, "epoch": epoch} + rotating.append((step if step is not None else epoch * 1e9, n)) + continue + out[n] = {"step": recorded.get(n), "prompt": "", "seed": seed, "epoch": None} + if prompts: + for k, (_, n) in enumerate(sorted(rotating)): + out[n]["prompt"] = prompts[k % len(prompts)] + return out diff --git a/studio/services/graph/tasks.py b/studio/services/graph/tasks.py new file mode 100644 index 0000000..9a86552 --- /dev/null +++ b/studio/services/graph/tasks.py @@ -0,0 +1,162 @@ +"""Read-only bridge from the Graph to the training queue. + +The Graph never changes queue state. It only reads which training tasks +exist, the config each one was frozen with, and its sample images — enough to +recognise a task that matches a card or an empty slot and to copy samples +into a card. +""" +from __future__ import annotations + +import logging +import re +from pathlib import Path +from typing import Any, Optional + +import yaml + +from ... import db +from ...infrastructure.paths import task_samples_dir +from .. import task_snapshot + +logger = logging.getLogger(__name__) + +#: (task_id) -> (mtime_ns, parsed config). Snapshots never change once frozen +#: except on a restart of the same task, which the mtime check catches. +_CFG_CACHE: dict[int, tuple[int, dict[str, Any]]] = {} + +_MAX_TASKS = 1000 + +#: (task_id) -> (mtime_ns, total_steps) read from the monitor state. +_TOTAL_CACHE: dict[int, tuple[int, Optional[int]]] = {} +_TOTAL_RE = re.compile(rb'"total_steps"\s*:\s*(\d+)') + + +def total_steps(task_id: int, monitor_state_path: Optional[str]) -> Optional[int]: + """Total optimizer steps the trainer planned, from the monitor state. + + Epoch-based runs keep ``max_steps: 0`` in their config, so this is the + only place the real total lives. The state file can be megabytes of loss + history, so the number is picked out with a regex instead of parsing it. + """ + if not monitor_state_path: + return None + p = Path(monitor_state_path) + try: + mtime = p.stat().st_mtime_ns + except OSError: + return None + hit = _TOTAL_CACHE.get(task_id) + if hit and hit[0] == mtime: + return hit[1] + try: + m = _TOTAL_RE.search(p.read_bytes()) + except OSError: + return None + val = int(m.group(1)) if m else None + _TOTAL_CACHE[task_id] = (mtime, val) + return val + + +def task_config(task_id: int, config_path: Optional[str] = None) -> Optional[dict[str, Any]]: + p = task_snapshot.snapshot_config_path(task_id) + if not p.exists() and config_path: + p = Path(config_path) + try: + mtime = p.stat().st_mtime_ns + except OSError: + return None + hit = _CFG_CACHE.get(task_id) + if hit and hit[0] == mtime: + return hit[1] + try: + parsed = yaml.safe_load(p.read_text(encoding="utf-8")) or {} + except Exception: # noqa: BLE001 - a broken yaml just means "unknown config" + logger.info("graph: unreadable config for task %s", task_id, exc_info=True) + parsed = {} + if not isinstance(parsed, dict): + parsed = {} + _CFG_CACHE[task_id] = (mtime, parsed) + return parsed + + +def list_train_tasks(keys: list[str]) -> list[dict[str, Any]]: + """Every training task, newest first, with only the config keys asked for.""" + with db.connection_for() as conn: + rows = conn.execute( + "SELECT t.id, t.name, t.status, t.created_at, t.started_at, t.finished_at, " + "t.project_id, t.version_id, t.config_path, t.monitor_state_path, t.note, " + "p.title AS project_title, v.label AS version_label " + "FROM tasks t " + "LEFT JOIN projects p ON p.id = t.project_id " + "LEFT JOIN versions v ON v.id = t.version_id " + "WHERE COALESCE(t.task_type, 'train') = 'train' " + "ORDER BY t.id DESC LIMIT ?", + (_MAX_TASKS,), + ).fetchall() + out: list[dict[str, Any]] = [] + for r in rows: + cfg = task_config(int(r["id"]), r["config_path"]) or {} + subset = {k: cfg[k] for k in keys if k in cfg} + if "max_steps" in keys and not cfg.get("max_steps"): + total = total_steps(int(r["id"]), r["monitor_state_path"]) + if total: + subset["max_steps"] = total + out.append({ + "id": r["id"], + "name": r["name"], + "status": r["status"], + "created_at": r["created_at"], + "started_at": r["started_at"], + "finished_at": r["finished_at"], + "project_id": r["project_id"], + "version_id": r["version_id"], + "project_title": r["project_title"], + "version_label": r["version_label"], + "note": r["note"] or "", + "has_config": bool(cfg), + "config": subset, + }) + return out + + +def task_row(task_id: int) -> Optional[dict[str, Any]]: + with db.connection_for() as conn: + row = conn.execute( + "SELECT id, config_path, monitor_state_path FROM tasks WHERE id = ?", (task_id,) + ).fetchone() + return dict(row) if row else None + + +def sample_file(task_id: int, filename: str) -> Optional[Path]: + """Resolve a task's sample by name through the same directories the + sample proxy searches.""" + from ...api.routers.samples import _sample_dirs + + row = task_row(task_id) + if not row: + return None + dirs = (_sample_dirs(row["monitor_state_path"], task_id) + if row["monitor_state_path"] else [task_samples_dir(task_id)]) + for d in dirs: + p = d / filename + if p.is_file(): + return p + return None + + +def monitor_summary(task_id: int) -> dict[str, Any]: + """total_steps / total_epochs / sample records from the monitor state + (only what sample steps need; the loss history is dropped).""" + import json + + row = task_row(task_id) + path = row and row.get("monitor_state_path") + if not path: + return {} + try: + data = json.loads(Path(path).read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + if not isinstance(data, dict): + return {} + return {k: data.get(k) for k in ("total_steps", "total_epochs", "samples")} diff --git a/studio/web/src/App.tsx b/studio/web/src/App.tsx index dfcb03f..d962cf1 100644 --- a/studio/web/src/App.tsx +++ b/studio/web/src/App.tsx @@ -35,6 +35,7 @@ import GeneratePage from './pages/tools/Generate' import MonitorPage from './pages/tools/Monitor' import PresetsPage from './pages/tools/Presets' import SoupPage from './pages/tools/Soup' +import GraphPage from './pages/tools/Graph' /** * Compatibility redirect for the old `/tools/settings?section=…` path: jumps to @@ -186,6 +187,7 @@ const router = createBrowserRouter( { path: '/tools/settings', element: }, { path: '/tools/generate', element: }, { path: '/tools/soup', element: }, + { path: '/tools/graph', element: }, { path: '/configs', element: }, { path: '/monitor', element: }, { path: '/datasets', element: }, diff --git a/studio/web/src/components/BaseModelSelect.tsx b/studio/web/src/components/BaseModelSelect.tsx index 44f1ab8..e1388df 100644 --- a/studio/web/src/components/BaseModelSelect.tsx +++ b/studio/web/src/components/BaseModelSelect.tsx @@ -62,7 +62,7 @@ export function useBaseModelOptions(family: BaseModelFamily = 'anima'): { for (const v of section.variants) { if (!v.exists) continue const badge = v.purpose - ? ` · ${t(`baseModel.purpose.${v.purpose}`)}` + ? `, ${t(`baseModel.purpose.${v.purpose}`)}` : '' out.push({ value: v.variant, diff --git a/studio/web/src/components/CommandPalette.tsx b/studio/web/src/components/CommandPalette.tsx index 99c5070..f04cf08 100644 --- a/studio/web/src/components/CommandPalette.tsx +++ b/studio/web/src/components/CommandPalette.tsx @@ -166,7 +166,7 @@ export default function CommandPalette({ open, onClose }: Props) { for (const task of tasks) { items.push({ id: `task:${task.id}`, - label: `#${task.id} · ${task.name}`, + label: `#${task.id} ${task.name}`, sub: t(`status.${task.status === 'pending' ? 'queued' : task.status}`), group: gTasks, kind: 'task', @@ -178,6 +178,7 @@ export default function CommandPalette({ open, onClose }: Props) { items.push({ id: 'home', label: t('commandPalette.home'), sub: t('commandPalette.homeSub'), group: gActions, kind: 'page', icon: 'folder', path: '/' }) items.push({ id: 'queue', label: t('nav.queue'), sub: t('commandPalette.queueSub'), group: gActions, kind: 'page', icon: 'queue', path: '/queue' }) items.push({ id: 'generate', label: t('nav.generate'), sub: t('commandPalette.generateSub'), group: gActions, kind: 'page', icon: 'image', path: '/tools/generate' }) + items.push({ id: 'graph', label: t('nav.graph'), sub: t('commandPalette.graphSub'), group: gActions, kind: 'page', icon: 'monitor', path: '/tools/graph' }) items.push({ id: 'presets', label: t('nav.presets'), sub: t('commandPalette.presetsSub'), group: gActions, kind: 'page', icon: 'preset', path: '/tools/presets' }) items.push({ id: 'monitor', label: t('nav.monitor'), sub: t('commandPalette.monitorSub'), group: gActions, kind: 'page', icon: 'monitor', path: '/tools/monitor' }) items.push({ id: 'settings', label: t('nav.settings'), sub: t('commandPalette.settingsSub'), group: gActions, kind: 'setting', icon: 'cog', action: () => settingsDrawer.open() }) @@ -342,7 +343,7 @@ export default function CommandPalette({ open, onClose }: Props) { {ITEM_ICONS[item.icon]} - {item.sub && · } + {item.sub && , } {t(`commandPalette.kind.${item.kind}`)} diff --git a/studio/web/src/components/DuplicateReviewPanel.tsx b/studio/web/src/components/DuplicateReviewPanel.tsx index a5657f2..65e7737 100644 --- a/studio/web/src/components/DuplicateReviewPanel.tsx +++ b/studio/web/src/components/DuplicateReviewPanel.tsx @@ -186,7 +186,7 @@ function DuplicateItemCell({ projectId, versionId, item, remove, busy, onToggle, }) { const { t } = useTranslation() const { folder, filename } = splitRel(item.name) - const title = `${item.name}\n${item.width}×${item.height} · ${item.filesize_kb} KB${item.metrics ? `\n${item.metrics.match_type} · ${item.metrics.score}` : ''}` + const title = `${item.name}\n${item.width}×${item.height}, ${item.filesize_kb} KB${item.metrics ? `\n${item.metrics.match_type}, ${item.metrics.score}` : ''}` return (
- - - ) } interface PathFieldProps { @@ -728,7 +725,7 @@ function PathStringField({ > {kind === 'path' ? ( // input + folder button on one row, as in the mockup - + )} {suffix && ( - {suffix} + {suffix} )} ) diff --git a/studio/web/src/components/InfoButton.tsx b/studio/web/src/components/InfoButton.tsx index 659c78e..099c0b1 100644 --- a/studio/web/src/components/InfoButton.tsx +++ b/studio/web/src/components/InfoButton.tsx @@ -1,88 +1,15 @@ -import { useEffect, useRef, useState, type ReactNode } from 'react' -import { useTranslation } from 'react-i18next' +import type { ReactNode } from 'react' +import HelpTip from './ds/HelpTip' -/** - * Click-toggle popover. Attaches help text to a field name / section title / - * card corner without taking up space in the main UI; only shows when the user needs it. - * - * Behavior: - * - Clicking the trigger toggles it; clicking outside / Esc closes it - * - aria-expanded for screen readers; the trigger carries its own aria-label - * - The trigger is an SVG i-in-circle icon (previously used the Unicode ⓘ - * character, whose vertical position was inconsistent across fonts and - * didn't align with the baseline of adjacent text; SVG with a fixed viewBox centers it precisely) - * - * Deliberately not done (YAGNI; extend later if another trigger need shows up): - * - No hover trigger support (not mobile-friendly) - * - No dynamic placement calculation (always bottom-left; viewport overflow handled via max-width) - */ +/** Help text next to a field name / section title / card corner. Kept as a + * named export for existing callers; it is the same "?" tip used everywhere. */ interface InfoButtonProps { children: ReactNode ariaLabel?: string } -function InfoIcon() { - return ( - - ) -} - export function InfoButton({ children, ariaLabel }: InfoButtonProps) { - const { t } = useTranslation() - const resolvedAriaLabel = ariaLabel ?? t('infoButton.label') - const [open, setOpen] = useState(false) - const wrapRef = useRef(null) - - useEffect(() => { - if (!open) return - const onDown = (e: MouseEvent) => { - if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false) - } - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setOpen(false) - } - document.addEventListener('mousedown', onDown) - document.addEventListener('keydown', onKey) - return () => { - document.removeEventListener('mousedown', onDown) - document.removeEventListener('keydown', onKey) - } - }, [open]) - - return ( - - - {open && ( -
- {children} -
- )} -
- ) + return {children} } + +export default InfoButton diff --git a/studio/web/src/components/JobProgress.tsx b/studio/web/src/components/JobProgress.tsx index 71cd0b3..80c617e 100644 --- a/studio/web/src/components/JobProgress.tsx +++ b/studio/web/src/components/JobProgress.tsx @@ -49,7 +49,7 @@ export default function JobProgress({ job, logs, onCancel }: Props) {
{elapsed && elapsed > 0 && ( - · {Math.round(elapsed)}s + {Math.round(elapsed)}s )} diff --git a/studio/web/src/components/LLMTaggerWorkspace.tsx b/studio/web/src/components/LLMTaggerWorkspace.tsx index 5f46e90..55482e4 100644 --- a/studio/web/src/components/LLMTaggerWorkspace.tsx +++ b/studio/web/src/components/LLMTaggerWorkspace.tsx @@ -440,9 +440,9 @@ function AdvancedSection({ preset, onUpdate }: { fontSize: 'var(--t-xs)', color: 'var(--fg-tertiary)', }} - title={`temperature ${preset.temperature} · max_tokens ${preset.max_tokens} · concurrency ${preset.concurrency} · max_requests_per_minute ${preset.max_requests_per_minute || 0} · max_side ${preset.max_side}px · jpeg_quality ${preset.jpeg_quality}`} + title={`temperature ${preset.temperature}, max_tokens ${preset.max_tokens}, concurrency ${preset.concurrency}, max_requests_per_minute ${preset.max_requests_per_minute || 0}, max_side ${preset.max_side}px, jpeg_quality ${preset.jpeg_quality}`} > - {preset.temperature} · {preset.max_tokens}t · c{preset.concurrency} · m{preset.max_requests_per_minute || 0} · {preset.max_side}px · q{preset.jpeg_quality} + {preset.temperature}, {preset.max_tokens}t, c{preset.concurrency}, m{preset.max_requests_per_minute || 0}, {preset.max_side}px, q{preset.jpeg_quality} {/* Expanded content: 03 Sampling + 04 Image preprocessing stacked as-is */} diff --git a/studio/web/src/components/ModelsRootMigrateModal.tsx b/studio/web/src/components/ModelsRootMigrateModal.tsx index 0ec0c04..095c173 100644 --- a/studio/web/src/components/ModelsRootMigrateModal.tsx +++ b/studio/web/src/components/ModelsRootMigrateModal.tsx @@ -230,7 +230,7 @@ export default function ModelsRootMigrateModal({ target, onClose, onDone }: {
{progress.currentFile} - {progress.doneFiles}/{progress.totalFiles} · {formatBytes(progress.doneBytes)}/{formatBytes(progress.totalBytes)} · {pct}% + {progress.doneFiles}/{progress.totalFiles}, {formatBytes(progress.doneBytes)}/{formatBytes(progress.totalBytes)}, {pct}%
diff --git a/studio/web/src/components/MonitorDashboard.tsx b/studio/web/src/components/MonitorDashboard.tsx index e06c4b5..af9a3c8 100644 --- a/studio/web/src/components/MonitorDashboard.tsx +++ b/studio/web/src/components/MonitorDashboard.tsx @@ -593,7 +593,7 @@ export function EvalMetricsPanel({ state, connected, taskId }: {
{t('monitor.metrics')}
- {state?.project_slug ?? `project ${pid}`} · {state?.version_label ?? `version ${vid}`} + {state?.project_slug ?? `project ${pid}`}, {state?.version_label ?? `version ${vid}`}
@@ -889,7 +889,7 @@ const SampleViewer = memo(function SampleViewer({ samples, taskId }: { const markText = [ curM.epoch != null ? `ep ${curM.epoch.toLocaleString()}` : null, curM.step != null ? `step ${curM.step.toLocaleString()}` : null, - ].filter(Boolean).join(' · ') + ].filter(Boolean).join(', ') return (
@@ -907,12 +907,12 @@ const SampleViewer = memo(function SampleViewer({ samples, taskId }: { const thumbTitle = [ m.epoch != null ? `ep ${m.epoch}` : null, m.step != null ? `step ${m.step}` : null, - ].filter(Boolean).join(' · ') || fn + ].filter(Boolean).join(', ') || fn // The badge sits on the line below the thumbnail, not overlaid on the 64px thumbnail (would be unreadable). const thumbCaption = [ m.epoch != null ? `ep${m.epoch}` : null, m.step != null ? `${m.step}` : null, - ].filter(Boolean).join('·') + ].filter(Boolean).join(', ') return ( diff --git a/studio/web/src/components/TriggerWordCard.tsx b/studio/web/src/components/TriggerWordCard.tsx index 6ec1410..6855176 100644 --- a/studio/web/src/components/TriggerWordCard.tsx +++ b/studio/web/src/components/TriggerWordCard.tsx @@ -71,46 +71,16 @@ export default function TriggerWordCard({ const missing = dopEnabled && !saved.trim() const inCaptions = detected?.candidates.find((c) => c.word.toLowerCase() === value.trim().toLowerCase()) - // A field row like the schema ones; under DOP it is the gated row the mockup - // draws with a green rule, since DOP cannot run without it. + // Same cell as the schema settings around it: name on top, the input with + // its actions in one line under it, then the caption candidates. return ( -
-
-
- - trigger_word - {dopEnabled && {t('trigger.gateDop')}} -
- {detected && detected.candidates.length > 0 && ( -
- {t('trigger.candidates', { n: detected.total })} - {detected.candidates.map((c) => ( - - ))} -
- )} - {detected && detected.total > 0 && detected.candidates.length === 0 && ( -
{t('trigger.noneFound')}
- )} - {detected && value.trim() && !inCaptions && detected.total > 0 && ( -
{t('trigger.notInCaptions')}
- )} - {missing &&
{t('trigger.dopNeedsTrigger')}
} +
+
+ + {dopEnabled && {t('trigger.gateDop')}} + {inCaptions && {t('trigger.inCaptionsPct', { pct: Math.round(inCaptions.coverage * 100) })}}
-
+
- - {inCaptions && {t('trigger.inCaptionsPct', { pct: Math.round(inCaptions.coverage * 100) })}} - - - + +
+ {detected && detected.candidates.length > 0 && ( +
+ {t('trigger.candidates', { n: detected.total })} + {detected.candidates.map((c) => ( + + ))} +
+ )} + {detected && detected.total > 0 && detected.candidates.length === 0 && ( +
{t('trigger.noneFound')}
+ )} + {detected && value.trim() && !inCaptions && detected.total > 0 && ( +
{t('trigger.notInCaptions')}
+ )} + {missing &&
{t('trigger.dopNeedsTrigger')}
}
) } diff --git a/studio/web/src/components/UploadProgressBar.tsx b/studio/web/src/components/UploadProgressBar.tsx index accf131..9ccd4ee 100644 --- a/studio/web/src/components/UploadProgressBar.tsx +++ b/studio/web/src/components/UploadProgressBar.tsx @@ -67,20 +67,17 @@ export default function UploadProgressBar({ state, className }: Props) { ) : isUploading ? ( <> {pct.toFixed(0)}% - · {formatBytes(state.loaded)} {state.total > 0 && ` / ${formatBytes(state.total)}`} {state.speedBps > 0 && ( <> - · {formatSpeed(state.speedBps)} )} {state.etaSec != null && ( <> - · {t('upload.etaPrefix')} {formatEta(state.etaSec)} diff --git a/studio/web/src/components/VersionStatusBadge.tsx b/studio/web/src/components/VersionStatusBadge.tsx index 07eda7b..4dbe0ee 100644 --- a/studio/web/src/components/VersionStatusBadge.tsx +++ b/studio/web/src/components/VersionStatusBadge.tsx @@ -50,7 +50,7 @@ export default function VersionStatusBadge({ {entry.dot && DOT_RUNNING} {STATUS_MAP[status] ? t(entry.key) : status} - {suffixKey ? ` · ${t(suffixKey)}` : ''} + {suffixKey ? `, ${t(suffixKey)}` : ''} ) } diff --git a/studio/web/src/components/ds/HelpTip.tsx b/studio/web/src/components/ds/HelpTip.tsx new file mode 100644 index 0000000..c74f458 --- /dev/null +++ b/studio/web/src/components/ds/HelpTip.tsx @@ -0,0 +1,81 @@ +import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' + +/** The one way the app explains something: a small "?" next to a name or a + * title. Hover or focus shows the bubble, a click pins it (touch screens). + * The bubble is portalled to so card overflow never clips it. */ +export default function HelpTip({ children, label }: { children: ReactNode; label?: string }) { + const { t } = useTranslation() + const id = useId() + const anchor = useRef(null) + const bubble = useRef(null) + const [hover, setHover] = useState(false) + const [pinned, setPinned] = useState(false) + const [pos, setPos] = useState<{ left: number; top: number } | null>(null) + const open = hover || pinned + + useLayoutEffect(() => { + if (!open || !anchor.current || !bubble.current) return + const a = anchor.current.getBoundingClientRect() + const b = bubble.current.getBoundingClientRect() + const vw = window.innerWidth + const vh = window.innerHeight + const left = Math.max(8, Math.min(a.left - 10, vw - b.width - 8)) + const top = a.bottom + 6 + b.height <= vh - 8 ? a.bottom + 6 : Math.max(8, a.top - 6 - b.height) + setPos({ left, top }) + }, [open]) + + useEffect(() => { + if (!pinned) return + const onDown = (e: MouseEvent) => { + const n = e.target as Node + if (anchor.current?.contains(n) || bubble.current?.contains(n)) return + setPinned(false) + } + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setPinned(false) } + document.addEventListener('mousedown', onDown) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', onDown) + document.removeEventListener('keydown', onKey) + } + }, [pinned]) + + return ( + <> + + {open && createPortal( + , + document.body, + )} + + ) +} diff --git a/studio/web/src/components/preprocess/FreeCropEditor.tsx b/studio/web/src/components/preprocess/FreeCropEditor.tsx index d6a3bb5..d469472 100644 --- a/studio/web/src/components/preprocess/FreeCropEditor.tsx +++ b/studio/web/src/components/preprocess/FreeCropEditor.tsx @@ -463,7 +463,7 @@ export default function FreeCropEditor({ >
{Math.round(draft.w * image.w)}×{Math.round(draft.h * image.h)} - · {arLabel(draft.w * image.w, draft.h * image.h)} + {arLabel(draft.w * image.w, draft.h * image.h)}
)} diff --git a/studio/web/src/components/preprocess/PreprocessJobStrip.tsx b/studio/web/src/components/preprocess/PreprocessJobStrip.tsx index b82e425..fb8bef1 100644 --- a/studio/web/src/components/preprocess/PreprocessJobStrip.tsx +++ b/studio/web/src/components/preprocess/PreprocessJobStrip.tsx @@ -39,7 +39,7 @@ export default function PreprocessJobStrip({ {job.status} job #{job.id} {elapsed && elapsed > 0 && ( - · {Math.round(elapsed)}s + {Math.round(elapsed)}s )} {lastLine} diff --git a/studio/web/src/components/preprocess/PreprocessToolsBar.tsx b/studio/web/src/components/preprocess/PreprocessToolsBar.tsx index b85f3cf..5689b62 100644 --- a/studio/web/src/components/preprocess/PreprocessToolsBar.tsx +++ b/studio/web/src/components/preprocess/PreprocessToolsBar.tsx @@ -180,7 +180,7 @@ export function PreprocessHeadTools({ projectId, versionId }: { projectId: numbe disabled={busy || s.processed === 0} title={t('ppFrame.restoreAllTitle')} > - {t('ppFrame.restoreAll')}{s.processed > 0 ? ` · ${s.processed}` : ''} + {t('ppFrame.restoreAll')}{s.processed > 0 ? `, ${s.processed}` : ''} {t('ppFrame.next')} diff --git a/studio/web/src/i18n/locales/en.json b/studio/web/src/i18n/locales/en.json index 0b0d6df..82602f8 100644 --- a/studio/web/src/i18n/locales/en.json +++ b/studio/web/src/i18n/locales/en.json @@ -15,7 +15,8 @@ "preprocess": "Preprocess (optional)", "overview": "Overview", "menu": "Menu", - "soup": "Soup" + "soup": "Soup", + "graph": "Graph" }, "common": { "cancel": "Cancel", @@ -106,7 +107,8 @@ "reg": "Reg set (optional)", "train": "Train", "download": "Download", - "soup": "Soup" + "soup": "Soup", + "graph": "Graph" }, "commandPalette": { "placeholder": "Search pages, projects, presets, tags…", @@ -145,7 +147,8 @@ "page": "page", "preset": "preset", "setting": "setting" - } + }, + "graphSub": "Compare training results" }, "sidebar": { "title": "Anima", @@ -329,6 +332,23 @@ }, "schema": { "fieldCount": "{{n}} items", + "subgroups": { + "loraMain": "Network parameters", + "loraDropout": "Dropout", + "loraExtra": "Extra network options", + "loraBlocks": "Per-block ranks", + "datasetMain": "Dataset settings", + "regSet": "Regularization set", + "trainMain": "Main parameters", + "trainLr": "Learning rate and optimizer", + "trainEma": "EMA settings", + "lossMain": "Loss", + "lossDop": "DOP settings", + "sysMain": "Main parameters", + "sysOpt": "Optimization", + "outMain": "Output and saving", + "outResume": "Continue training" + }, "groups": { "model": "Model paths", "dataset": "Dataset", @@ -3139,6 +3159,7 @@ "tab_data": "Data", "tab_optim": "Training", "tab_system": "System & output", + "tab_finish": "Finishing", "cfgSub": "version config · config.yaml", "changeConfig": "Change config", "pickConfig": "Pick a preset", @@ -3146,6 +3167,7 @@ "saving": "Saving…", "savedAt": "Saved {{time}}", "saveError": "Save failed", + "subtitleModel": "Model {{model}}, {{type}} rank {{rank}}", "nParams_one": "{{count}} parameter", "nParams_other": "{{count}} parameters", "resetTab": "Reset the section to defaults", @@ -4070,5 +4092,346 @@ "dupFiles_other": "{{count}} files · keep one", "dupKeepBest": "Keep best", "dupKeep": "keep" + }, + "graph": { + "eyebrow": "Tools", + "title": "Graph", + "subtitle": "Keep and compare training results: what has been tried, what hasn't, and where the results get better.", + "stats": "{{total}} trainings · {{withSamples}} with samples · {{planned}} planned", + "welcomeTitle": "Start a board", + "welcomeBody": "A board holds its own parameters and training cards. Make one per style or per LoRA.", + "welcome1": "Create a board — it starts with the usual parameters (LR, optimizer, rank…).", + "welcome2": "Add a card per training: set its parameters and drop sample images onto it, or take a training from the queue.", + "welcome3": "Use the Matrix to see untried combinations and start new trainings right from an empty cell.", + "newBoard": "New board", + "newBoardSub": "Every board has its own parameters and cards.", + "boardNamePh": "e.g. Ghibli style", + "startWith": "Start with", + "startDefault": "Standard parameters", + "startEmpty": "No parameters", + "startCopy": "Parameters of “{{name}}”", + "board": "Board", + "boards": "Boards", + "boardCounts": "{{runs}} cards · {{images}} images", + "renameBoard": "Rename board", + "deleteBoard": "Delete board", + "deleteBoardConfirm": "Delete board “{{name}}” with {{runs}} cards and {{images}} images? This can't be undone.", + "nameRequired": "Enter a name", + "parameters": "Parameters", + "fromQueue": "From queue", + "addCard": "Add training", + "searchPh": "Search by name, notes, tags…", + "viewMode": "View", + "view_cards": "Cards", + "view_table": "Table", + "view_matrix": "Matrix", + "view_web": "Web", + "viewWeb": "Web of trainings", + "showGaps": "Empty places", + "toggleFilters": "Show / hide filters", + "filters": "Filters", + "groupBy": "Group", + "groupByTitle": "Group cards by (in order)", + "noGrouping": "No grouping — all cards in one field.", + "addLevel": "+ Add level", + "sort": "Sort", + "sortTitle": "Sort by (first rule wins)", + "sortCreated": "Created", + "asc": "↑ asc", + "desc": "↓ desc", + "cardFields": "Card fields", + "cardFieldsTitle": "Parameters shown on cards", + "thumbSize": "Preview size", + "views": "Views", + "viewsTitle": "Saved views", + "noViews": "No saved views yet.", + "saveView": "Save current view…", + "saveViewAs": "Name for this view", + "saveViewPh": "e.g. LR comparison", + "viewsNote": "A view keeps filters, grouping, sorting and mode ({{mode}}). The current view is remembered automatically.", + "resetFilters": "Reset filters", + "fStatus": "Status", + "fMore": "Show only", + "fWithSamples": "With samples", + "fWithoutSamples": "Without samples", + "fFavorites": "Favorites", + "fMinRating": "Rating from", + "fContains": "Contains…", + "fUntried": "No cards with this value yet", + "clear": "clear", + "from": "from", + "to": "to", + "ifShort": "if {{name}}", + "conditionalHint": "Shown only for some values of {{name}}", + "status_planned": "Planned", + "status_training": "Training", + "status_done": "Done", + "status_stopped": "Stopped", + "task_pending": "waiting", + "task_scheduled": "scheduled", + "task_running": "running", + "task_paused": "paused", + "task_done": "done", + "task_failed": "failed", + "task_canceled": "canceled", + "unset": "Not set", + "unsetShort": "—", + "yes": "Yes", + "no": "No", + "rating": "Rating", + "rateN": "Rate {{n}}", + "rating_1": "Weak", + "rating_2": "Good", + "rating_3": "Great", + "notRated": "Not rated", + "untried": "Not tried", + "favorite": "Favorite", + "untitled": "Untitled", + "open": "Open", + "openCard": "Open {{name}}", + "cardActions": "Card actions", + "cardDetails": "Training card", + "addSamples": "Add samples", + "duplicate": "Copy and change", + "duplicated": "Copy created — change the parameters you want", + "oneAway": "Differs by one parameter", + "selectCompare": "Select for comparison", + "unselect": "Remove from selection", + "createTraining": "Create training", + "createTrainingShort": "Train", + "downloadConfig": "Download config", + "noConfigToDownload": "This card has no linked training and no parameters tied to the config.", + "fragmentHeader": "Graph: parameters of this card only (no training linked). Merge into a full config.", + "fragmentSaved": "No linked training — saved the config part built from the card's parameters.", + "deleteCardConfirm": "Delete card “{{name}}” and its {{count}} images?", + "noSamples": "No samples — drop images here", + "dropHere": "Drop to add", + "stepN": "step {{n}}", + "epochN": "epoch {{n}}", + "differsIn": "Differs in: {{name}}", + "planned": "Added as planned", + "plan": "Plan", + "emptyPlace": "Not tried yet", + "inQueue": "Already in the queue:", + "adoptTaskHint": "Put this queue training on the board", + "nCards_one": "{{count}} card", + "nCards_other": "{{count}} cards", + "nEmpty_one": "{{count}} empty", + "nEmpty_other": "{{count}} empty", + "nRuns_one": "{{count}} run", + "nRuns_other": "{{count}} runs with the same settings", + "nSamples_one": "{{count}} sample", + "nSamples_other": "{{count}} samples", + "stackDiffers": "highlighted: what differs", + "stackSame": "identical settings", + "compare": "Compare", + "compareAll": "Compare all", + "selectedN_one": "{{count}} selected", + "selectedN_other": "{{count}} selected", + "clearSelection": "Clear", + "pasteTarget": "Ctrl+V pastes an image into it", + "pasteWhere": "Open a card (or select one) to paste an image into it.", + "uploadedN_one": "{{count}} image added", + "uploadedN_other": "{{count}} images added", + "coverage": "{{filled}} of {{total}} combinations tried", + "gapsHint": "Pick values in the filters on the left (e.g. five LR and two schedulers) — the board shows every combination and marks the empty ones.", + "tooManyGaps": "Too many combinations (over {{n}}). Fix a few parameters to one value in the filters.", + "neighboursBanner": "Cards that differ from “{{name}}” in exactly one parameter: {{count}}", + "backToBoard": "Back to board", + "emptyBoardTitle": "No trainings on this board yet", + "emptyBoardBody": "Add a card by hand, take a training from the queue, or open the Matrix and plan what to try.", + "planInMatrix": "Plan in Matrix", + "nothingMatches": "Nothing matches the filters.", + "training": "Training", + "taskGone": "The task was removed from the queue — the card and its images are kept.", + "importSamples": "Import samples", + "openInQueue": "Open in queue", + "openVersion": "Open version", + "unlinkTask": "Unlink", + "looksLike": "Looks like task #{{id}}", + "link": "Link", + "linkTask": "Link a task", + "pickTask": "Pick a queue training…", + "samples": "Samples", + "stepForNew": "step", + "stepForNewHint": "Training step for newly added images. Empty — read it from the file name (step_400, …-000800).", + "dropSamples": "Drop images here, choose files or paste with Ctrl+V", + "dropSamplesHint": "PNG, JPEG, WebP. Images are copied into the app — moving the originals won't break the card.", + "noStep": "No step", + "bestStep": "Best step", + "pasteHint": "Tip: Ctrl+V pastes an image from the clipboard into this card.", + "hiddenValues": "Hidden parameters", + "archivedParams": "Archived parameters", + "archived": "archived", + "noNeighbours": "No cards differ from this one by a single parameter yet.", + "showOnBoard": "Show on board", + "notesPh": "What you noticed: strengths, problems, ideas…", + "resultsLink": "Link / path", + "resultsLinkPh": "https://… or D:\\loras\\run7", + "tags": "Tags", + "tagsPh": "overfit, lost style…", + "badNumber": "Not a number", + "sample": "Sample", + "prev": "Previous", + "next": "Next", + "step": "Step", + "stepHint": "The training step the sample was made at (not the total steps).", + "prompt": "Prompt", + "seed": "Seed", + "comment": "Comment", + "setBestStep": "Mark this step as best", + "unsetBestStep": "Unmark best step", + "deleteSampleConfirm": "Delete this sample from the card?", + "source": "Source", + "compareTitle": "Comparison", + "compareSub": "Wheel — zoom, drag — pan, double click — 3× / fit. Highlighted parameters differ.", + "allSteps": "All steps", + "bestOfEach": "Best of each", + "any": "Any", + "syncZoom": "Sync zoom", + "fit": "Fit", + "commonParams_one": "Same in all: {{count}} parameter", + "commonParams_other": "Same in all: {{count}} parameters", + "removeFromCompare": "Remove from comparison", + "noSampleAt": "No sample at step {{n}}", + "noSample": "No sample", + "moreMatches": "More images match this step", + "openFull": "Open full size", + "compareNoImages": "These cards have no samples yet.", + "axisX": "Columns", + "axisY": "Rows", + "swapAxes": "Swap axes", + "matrixNone": "— none —", + "matrixPick": "Pick a parameter", + "matrixHint": "Pick a parameter for the columns (and optionally the rows). Other parameters stay as filters.", + "webColor": "Color by", + "webStats": "{{nodes}} cards · {{edges}} links · {{alone}} without a neighbour", + "webReset": "Reset view", + "webCapped": "Only the first {{n}} cards are drawn — narrow the filters.", + "webEmpty": "No cards to draw.", + "webHelp": "A line joins two cards that differ in exactly one parameter (hover to see which). Wheel — zoom, drag — move.", + "paramsTitle": "Parameters", + "paramsSub": "Rename, reorder, hide or archive parameters and values. Existing cards keep their links.", + "remapsPending_one": "{{count}} change to cards on save", + "remapsPending_other": "{{count}} changes to cards on save", + "paramsSaved": "Parameters saved", + "newParamName": "New parameter name", + "paramType": "Type", + "type_list": "List", + "type_number": "Number", + "type_bool": "Yes / No", + "type_text": "Free text", + "addParam": "Add", + "archive": "Archive", + "usedIn_one": "in {{count}} card", + "usedIn_other": "in {{count}} cards", + "moveUp": "Move up", + "moveDown": "Move down", + "showParam": "Show parameter", + "hideParam": "Hide temporarily", + "edit": "Edit", + "description": "Explanation", + "descriptionPh": "Optional hint shown on hover", + "condition": "Show when", + "alwaysShown": "Always", + "whenParam": "when {{name}} is…", + "conditionPick": "Pick at least one value, otherwise the parameter never shows.", + "values": "Values", + "valueLabel": "Value", + "addValues": "Add", + "addValuesPh": "New values, comma separated", + "numberValuesHint": "Suggested values; a card can hold any number. 0.0001 and 1e-4 are the same value.", + "noValuesYet": "No suggested values yet", + "editValue": "Click to change", + "range": "Range", + "stepSize": "step", + "addNValues_one": "Add {{count}} value", + "addNValues_other": "Add {{count}} values", + "deleteParam": "Delete parameter", + "deleteParamUsed_one": "Used in {{count}} card. Archive keeps the history; deleting clears the value from the card.", + "deleteParamUsed_other": "Used in {{count}} cards. Archive keeps the history; deleting clears the value from those cards.", + "deleteValueUsed_one": "Used in {{count}} card. Archive hides it from pickers but keeps the history.", + "deleteValueUsed_other": "Used in {{count}} cards. Archive hides it from pickers but keeps the history.", + "archiveInstead": "Archive", + "deleteAnyway": "Delete anyway", + "showConfigLink": "Config link (advanced)", + "hideConfigLink": "Hide config link", + "configLinkHelp": "The training config key this number is written to and read from.", + "configKey": "Config key", + "configList": "key holds a list", + "configApply": "Writes to config (JSON)", + "configMatch": "Recognised in config by (JSON, optional)", + "badJson": "Invalid JSON object", + "createTitle": "Create training", + "createSub": "A new version is copied from a base version (dataset, reg set, config) with these parameters.", + "noValuesSet": "No parameters set — the copy keeps the base config.", + "noProjects": "There are no projects yet — create one first.", + "goProjects": "Go to projects", + "project": "Project", + "baseVersion": "Copy from version", + "baseVersionHint": "Dataset, captions, reg set and the rest of the config come from here.", + "baseNoConfig": "This version has no training config yet. Open it on the Train step first, or pick another version.", + "newVersionLabel": "New version name", + "labelRequired": "Enter a name", + "labelInvalid": "Letters, digits, _ . - only", + "labelTaken": "This project already has a version with this name", + "configChanges": "Config changes", + "noConfigChanges": "Nothing to change — the parameters aren't tied to the config.", + "notApplied": "Not written to the config (no config link): {{names}}.", + "queueNow": "Add to the queue right away", + "creating": "Creating…", + "createAndQueue": "Create and queue", + "createVersion": "Create version", + "createFailed": "Could not create the training", + "queueFailed": "The version was created and linked, but it could not be queued: {{msg}}", + "versionNote": "Created from Graph", + "trainingQueued": "Version {{label}} created and queued", + "versionCreated": "Version {{label}} created", + "importTitle": "Take a training from the queue", + "importSub": "Its parameters are read from the frozen config; samples can be copied in the next step.", + "searchTasks": "Search by project, version, #id…", + "noTasks": "No training tasks found.", + "nothingRecognised": "No board parameter recognised in its config", + "noTaskConfig": "No saved config for this task", + "alreadyOnBoard": "Already on this board", + "pickSamplesTitle": "Samples of task #{{id}}", + "pickSamplesSub": "Pick samples by step. They are copied into the card, so deleting the task won't remove them.", + "taskNoSamples": "This task has no samples.", + "alreadyAdded": "added", + "importing": "Importing…", + "importN_one": "Import {{count}}", + "importN_other": "Import {{count}}", + "importedN_one": "{{count}} sample imported", + "importedN_other": "{{count}} samples imported", + "configAdjusted": "The app changed incompatible settings on save: {{keys}}. The card shows what will actually train.", + "showGapsHint": "Shows untried combinations of the values picked below", + "andHigher": "and higher", + "noGroupingShort": "No grouping", + "groupByParam": "Group: {{name}}", + "sortNewest": "Newest first", + "sortOldest": "Oldest first", + "sortBest": "Best rated first", + "sortName": "By name", + "sortByParam": "By {{name}} ↑", + "viewSettings": "View settings", + "thenGroup": "Then group by", + "thenSort": "Then sort by", + "none": "—", + "openTraining": "Open training", + "versionOnly": "The version exists but isn't queued yet", + "colorNone": "No colors", + "colorBy": "Color: {{name}}", + "sampleSize": "Sample size", + "size_s": "Small", + "size_m": "Medium", + "size_l": "Large", + "addBlank": "Empty card", + "addBlankSub": "Fill in the parameters yourself", + "fromQueueSub": "Parameters and samples come from a training", + "result": "Result", + "dropShort": "Drop or paste (Ctrl+V)", + "dropShortSub": "PNG, JPEG, WebP", + "auto": "auto", + "pickFiles": "Choose files" } } diff --git a/studio/web/src/i18n/locales/ru.json b/studio/web/src/i18n/locales/ru.json index d1a0c92..d5a2e36 100644 --- a/studio/web/src/i18n/locales/ru.json +++ b/studio/web/src/i18n/locales/ru.json @@ -15,15 +15,16 @@ "preprocess": "Препроцесс (необязательно)", "overview": "Обзор", "menu": "Меню", - "soup": "Суп" + "soup": "Суп", + "graph": "График" }, "common": { "cancel": "Отмена", "confirm": "Подтвердить", "ok": "ОК", - "zoomHint": "Колесо — масштаб · перетаскивание — сдвиг · двойной клик — вписать/100%", + "zoomHint": "Колесо — масштаб, перетаскивание — сдвиг, двойной клик — вписать или 100%", "zoomFit": "Вписать", - "cropZoomHint": "Колесо — масштаб · пробел+перетаскивание — сдвиг", + "cropZoomHint": "Колесо — масштаб, пробел с перетаскиванием — сдвиг", "gotIt": "Понятно", "save": "Сохранить", "delete": "Удалить", @@ -146,7 +147,7 @@ "title": "Тренировка", "subtitle": "Выбрать пресет → правка конфига → в очередь" }, - "eyebrowStep": "Шаг {{n}} · версия {{label}}", + "eyebrowStep": "Шаг {{n}}, версия {{label}}", "stepN": "ШАГ {{n}}" }, "status": { @@ -182,13 +183,14 @@ "reg": "Reg-набор (необязательно)", "train": "Тренировка", "download": "Скачивание", - "soup": "Суп" + "soup": "Суп", + "graph": "График" }, "topbar": { "search": "Поиск (⌘K)", "searchAriaLabel": "Поиск", "newVersion": "Доступна новая версия {{tag}}", - "trainingCapsule": "Тренировка · {{label}}{{suffix}}", + "trainingCapsule": "Тренировка {{label}}{{suffix}}", "pendingCount": "{{n}} в очереди", "taskId": "Задача #{{id}}", "runningETA": "~{{time}}", @@ -201,8 +203,8 @@ "infoTitle": "Информация" }, "field": { - "autoProject": "Авто · управляется проектом", - "autoGlobal": "Авто · из общих настроек", + "autoProject": "Авто, задаётся проектом", + "autoGlobal": "Авто, из общих настроек", "pathHint": "(путь)", "pathHintAuto": "(путь)", "browse": "Обзор", @@ -216,8 +218,8 @@ "more": "Больше", "was": "было: {{value}}", "familyDesc": { - "anima": "Anima Base 2B · энкодер Qwen3-0.6B · теги Danbooru и обычный текст", - "krea2": "Krea 2 MMDiT · энкодер Qwen3-VL-4B · подписи обычным языком" + "anima": "Anima Base 2B, энкодер Qwen3-0.6B, теги Danbooru и обычный текст", + "krea2": "Krea 2 MMDiT, энкодер Qwen3-VL-4B, подписи обычным языком" } }, "versionPhase": { @@ -252,7 +254,7 @@ "saved": "Сохранено", "restorePoints": "Точки восстановления", "noRestorePoints": "Точек пока нет. Создаются автоматически при сохранении.", - "restoreEntry": "{{n}} файлов · {{size}}", + "restoreEntry": "{{n}} файлов, {{size}}", "confirmRestore": "Восстановление перезапишет все капшены, включая несохранённые правки. Продолжить?", "restoreDone": "Восстановлено (записано {{n}}, удалено {{m}})", "confirmDeletePoint": "Удалить точку восстановления {{id}}? Действие необратимо.", @@ -350,17 +352,17 @@ "tag_recall": "Возврат booru-тегов промпта" }, "ckptsTitle": "Чекпоинты", - "ckptsSub_one": "{{count}} чекпоинт · зелёным — лучший по DINO-I", - "ckptsSub_few": "{{count}} чекпоинта · зелёным — лучший по DINO-I", - "ckptsSub_many": "{{count}} чекпоинтов · зелёным — лучший по DINO-I", - "ckptsSub_other": "{{count}} чекпоинта · зелёным — лучший по DINO-I", + "ckptsSub_one": "{{count}} чекпоинт, лучший по DINO-I выделен зелёным", + "ckptsSub_few": "{{count}} чекпоинта, лучший по DINO-I выделен зелёным", + "ckptsSub_many": "{{count}} чекпоинтов, лучший по DINO-I выделен зелёным", + "ckptsSub_other": "{{count}} чекпоинта, лучший по DINO-I выделен зелёным", "viewTable": "Таблица", "viewChart": "График", "ckptCol": "Чекпоинт", "deltaCol": "Δ база", "bestBadge": "лучший", "dynamicsTitle": "Динамика по эпохам", - "dynamicsSub": "DINO-I · пик на {{ckpt}}", + "dynamicsSub": "DINO-I, пик на {{ckpt}}", "overcookNote": "После {{ckpt}} DINO-I падает — похоже на пережарку. Берите {{ckpt}}.", "samplesTitle": "Сэмплы {{ckpt}}", "samplesSub": "Сэмплы тренировки на этом чекпоинте", @@ -477,11 +479,11 @@ "noMatch": "Нет совпадений с «{{search}}»", "loadingConfig": "Загрузка конфига тренировки", "noConfigHint": "Нажмите «+ Новый пресет» слева — он соберётся из значений по умолчанию и путей к моделям проекта и применится. Либо выберите готовый пресет.", - "globalAutoHint": "Авто · из общих настроек", + "globalAutoHint": "Авто, из общих настроек", "globalAutoLockedPrefix": "Авто", "globalAutoLockedLink": "Общие настройки", - "globalAutoEditableHint": "Значение из настроек · можно менять", - "projectAutoHint": "Авто · из настроек проекта", + "globalAutoEditableHint": "Из общих настроек, можно менять", + "projectAutoHint": "Авто, из настроек проекта", "resetToGlobalDefault": "↺ Вернуть общее значение", "resetToGlobalDefaultTitle": "Подставить текущее значение из «Настройки → Модели»", "statsTitle": "Параметры датасета", @@ -507,14 +509,14 @@ "navitDistDownscaled": "{{n}} изобр. превысили бюджет токенов и были уменьшены.", "navitDistMore": "… ещё {{kinds}} размеров, всего {{n}} изобр.", "navitBucketHint": "Упаковка NaViT: корзины только квантуют размеры; батчи набираются поверх корзин по бюджету токенов — batch_size не участвует.", - "maskedLossHint": "У {{n}} изображений есть маски (Inpaint · Mask), но masked_loss выключен — этот прогон их не использует. Включите masked_loss в группе loss.", + "maskedLossHint": "У {{n}} изображений есть маски из Inpaint, но masked_loss выключен, и этот прогон их не использует. Включите masked_loss в группе loss.", "confirmReset": "Сбросить конфиг к пресету {{name}}? Все правки текущей версии пропадут.", "resetOkText": "Сбросить", "resetSuccess": "Сброшено к конфигу пресета {{name}}", "pickerTitleConfigured": "У версии свой конфиг; нажмите, чтобы выбрать пресет как отправную точку для сброса", "pickerTitleEmpty": "Нажмите, чтобы выбрать пресет как отправную точку и создать конфиг версии", "configChip": "конфиг", - "scopedConfigLabel": "{{title}} · {{label}}", + "scopedConfigLabel": "{{title}}, {{label}}", "notConfiguredLabel": "Не настроено — выберите пресет", "readPresetParams": "Прочитать параметры из этого пресета", "estDuration": "Примерно займёт", @@ -530,13 +532,15 @@ "tab_data": "Данные", "tab_optim": "Обучение", "tab_system": "Система и вывод", - "cfgSub": "конфиг версии · config.yaml", + "tab_finish": "Доработка", + "cfgSub": "Конфиг версии в config.yaml", "changeConfig": "Сменить конфиг", "pickConfig": "Выбрать пресет", "configActions": "Действия с конфигом", "saving": "Сохраняю…", "savedAt": "Сохранено {{time}}", "saveError": "Ошибка сохранения", + "subtitleModel": "Модель {{model}}, {{type}} ранга {{rank}}", "nParams_one": "{{count}} параметр", "nParams_few": "{{count}} параметра", "nParams_many": "{{count}} параметров", @@ -548,19 +552,19 @@ "resetTabConfirm_other": "Вернуть {{count}} поля раздела «{{tab}}» к значениям по умолчанию? Пути и поля проекта не трогаются.", "resetTabOk": "Сбросить", "resetTabNothing": "В разделе всё уже по умолчанию", - "kpiSamples": "Сэмплов в эпохе · с повторами", - "kpiSamplesSplit": "train {{train}} · reg {{reg}}", + "kpiSamples": "Сэмплов в эпохе с повторами", + "kpiSamplesSplit": "train {{train}}, reg {{reg}}", "kpiSamplesTrainOnly": "только train", "kpiStepsPerEpoch": "{{n}} шагов / эпоха", "stepsUnit": "шагов", - "kpiStepsTime": "Всего · ≈ {{time}} при {{speed}} it/s", - "kpiStepsNoSpeed": "Всего · время появится после первого запуска", - "kpiVram": "Пик VRAM · swap {{swap}} / {{total}} блоков", - "kpiVramNone": "Пик VRAM · оценки нет (нет CUDA или модели)", + "kpiStepsTime": "Всего, примерно {{time}} при {{speed}} it/s", + "kpiStepsNoSpeed": "Всего, время появится после первого запуска", + "kpiVram": "Пик VRAM, в свопе {{swap}} из {{total}} блоков", + "kpiVramNone": "Пик VRAM не оценить: нет CUDA или модели", "kpiCpu": "CPU {{n}}%", "pctGpu": "% GPU", "pctCpu": "% CPU", - "kpiLoadGpu": "Нагрузка на систему · {{name}}", + "kpiLoadGpu": "Нагрузка на {{name}}", "kpiLoad": "Нагрузка на систему", "durHM": "{{h}} ч {{m}} мин", "durH": "{{h}} ч", @@ -580,18 +584,18 @@ "budgetPlusReg": "+ reg", "budgetNavitSamples": "по оценке упаковки NaViT", "budgetSteps": "Шагов оптимизатора / эпоха", - "budgetStepsNote": "÷ batch {{bs}} × grad_accum {{ga}} · ×{{epochs}} эпох = {{total}}", + "budgetStepsNote": "÷ batch {{bs}} × grad_accum {{ga}} × {{epochs}} эпох = {{total}}", "budgetNavitSteps": "{{packs}} пачек NaViT ÷ grad_accum {{ga}}", "previewTitle": "Предпросмотр", "previewSub": "Обновляется на лету", - "compositionTitle": "Состав эпохи · {{n}} сэмплов", + "compositionTitle": "Состав эпохи ({{n}} сэмплов)", "trainImagesShort": "изобр. train", "statStepsEpoch": "Шагов / эпоха", "statEpochs": "Эпох", "memoryTitle": "Память", - "bucketRest": "прочие · {{n}}", + "bucketRest": "прочие ({{n}})", "mapTitle": "Карта конфига", - "mapSub": "{{groups}} групп схемы · {{fields}} полей · клик по строке открывает нужную вкладку", + "mapSub": "{{groups}} групп, {{fields}} полей", "mapSection": "Раздел", "mapFields": "Полей", "mapChanged": "Изменено", @@ -669,7 +673,7 @@ "sort_queue": "Сначала в очереди", "sort_finish": "По времени окончания", "trainingNow": "Тренируется сейчас", - "queuePosition": "В очереди · №{{position}}", + "queuePosition": "№{{position}} в очереди", "finishUnknown": "время уточняется", "archivedToggle": "В архиве ({{n}})", "archivedToggleHint": "Показать только архивные проекты", @@ -703,7 +707,7 @@ "versions_many": "{{count}} версий", "versions_other": "{{count}} версии", "modelReady": "Модель готова", - "stepOf": "Шаг {{n}} из 5 · {{step}}", + "stepOf": "Шаг {{n}} из 5: {{step}}", "waitingSlot": "ждёт слот", "doneAt": "Готово {{when}}", "startAt": "старт ≈ {{time}}", @@ -758,7 +762,7 @@ "open": "Открыть", "activateAndOpen": "Сделать активной и открыть", "viewOutput": "Посмотреть результат", - "subtitle": "{{n}} загрузок · {{v}} версий", + "subtitle": "{{n}} загрузок, {{v}} версий", "continueStep": "Продолжить → {{label}}", "totalDownload": "Всего загружено", "currentVersion": "Текущая: {{label}}", @@ -811,7 +815,7 @@ "imagesSuffix": "изобр.", "tagSuffix": "тегов", "reorganize": "Перестроить", - "regEmptyHint": "Не собран · пропустите шаг в ⑥", + "regEmptyHint": "Не собран, шаг можно пропустить в ⑥", "allFolders": "Все" }, "identity": { @@ -838,7 +842,7 @@ "viewLog": "Смотреть лог", "deleteVersion": "Удалить версию", "forkConfigNew": "Скопировать конфиг в новую версию", - "smallerBatchRetry": "Копия конфига · меньший батч", + "smallerBatchRetry": "Копия конфига с меньшим батчем", "startedAt": "Начало", "scheduledTitle": "Тренировка запланирована", "scheduledFor": "Запланирована на", @@ -850,8 +854,8 @@ "loadInTest": "Загрузить в тесте", "downloadLora": "Скачать", "continueLabel": "Продолжить", - "preparingSub": "Подготовка датасета · тренировку можно запустить после неё", - "smallerBatchHint": "Создана новая версия · поправьте batch_size в ⑦ и запустите снова", + "preparingSub": "Готовим датасет, после этого можно запускать тренировку", + "smallerBatchHint": "Создана новая версия. Поправьте batch_size в ⑦ и запустите снова", "noArtifact": "Файла результата ещё нет, продолжить нельзя" }, "header": { @@ -888,7 +892,7 @@ }, "recentRunsCaption": "Последние прогоны", "noRuns": "Прогонов пока нет.", - "eyebrow": "Проект · {{slug}}", + "eyebrow": "Проект {{slug}}", "downloads_one": "{{count}} загрузка", "downloads_few": "{{count}} загрузки", "downloads_many": "{{count}} загрузок", @@ -896,7 +900,7 @@ "createdOn": "создан {{date}}", "activeLabel": "активная", "editProject": "Изменить проект", - "pipelineSub": "Этапы версии {{label}} · курсор на шаге {{n}}", + "pipelineSub": "Этапы версии {{label}}, сейчас шаг {{n}}", "noVersion": "У проекта пока нет версий", "cellDone": "готово", "cellCurrent": "текущий шаг", @@ -908,12 +912,12 @@ "kpiTrain": "Изображений в train-наборе", "kpiDelta": "{{n}} к {{label}}", "kpiCaptioned": "С капшенами", - "kpiCaptionedUnique": "С капшенами · {{n}} уникальных тегов", + "kpiCaptionedUnique": "С капшенами, {{n}} уникальных тегов", "kpiAllCaptioned": "все капшены на месте", "kpiMissingCaptions": "без капшенов: {{n}}", "kpiVersionsTotal": "всего {{v}}", - "kpiActiveVersion": "Активная версия · {{state}}", - "kpiShownVersion": "Выбранная версия · {{state}}", + "kpiActiveVersion": "Активная версия: {{state}}", + "kpiShownVersion": "Выбранная версия: {{state}}", "kpiPhase": "фаза «{{phase}}»", "kpiCkpts": "Чекпоинтов в output/", "kpiLastCkpt": "последний — {{label}}", @@ -944,20 +948,20 @@ "verTraining": "Тренируется", "verTrained": "Натренирована", "verPaused": "На паузе", - "verPreparing": "Подготовка · {{phase}}", + "verPreparing": "Подготовка: {{phase}}", "verTrain": "{{n}} изобр. в train", "verReg": "{{n}} в reg", "verNoReg": "без reg", "verCkpt": "чекпоинт готов", "compTitle": "Состав набора", - "compSub": "{{total}} изобр. · {{train}} в train, {{reg}} в reg", + "compSub": "{{total}} изобр., {{train}} в train, {{reg}} в reg", "compInTrain": "в train", "compOther": "прочие", "compReg": "reg-набор", - "compResBuckets": "Разрешения · бакеты", + "compResBuckets": "Разрешения и бакеты", "compEmpty": "Train-набор пока пуст — отберите изображения на шаге «Отбор».", "topTags": "Топ тегов", - "topTagsSub": "{{tagged}} / {{total}} с тегами · {{unique}} уникальных", + "topTagsSub": "{{tagged}} из {{total}} с тегами, {{unique}} уникальных", "topTagsEmpty": "Капшенов пока нет.", "fieldTitle": "Название", "fieldNote": "Заметка", @@ -1333,15 +1337,15 @@ "reg_caption": "Капшен для reg", "reg_weight": "Вклад reg в loss", "shuffle_caption": "Перемешивать теги", - "keep_tokens": "Сохранять первых тегов", + "keep_tokens": "Сохранять позицию тегов", "flip_augment": "Отражение по горизонтали", "tag_dropout": "Выбрасывание тегов", "prefer_json": "Предпочитать .json", "caption_comfy_encoding": "Кодирование как в ComfyUI", "cache_latents": "Кешировать латенты", "vae_cache_batch_size": "Батч кеша VAE", - "blocks_to_swap": "Блоков в swap", - "block_swap_preflight": "Проверка swap до старта", + "blocks_to_swap": "Замена блоков", + "block_swap_preflight": "Проверка замены блоков", "navit_packing": "Упаковка NaViT", "navit_token_budget": "Бюджет токенов NaViT", "navit_max_images_per_pack": "Картинок в упаковке", @@ -1357,27 +1361,27 @@ "cache_encode_max_pixels": "Предел пикселей при кешировании", "lora_type": "Тип сети", "lora_rank": "Ранг", - "lora_alpha": "Alpha", + "lora_alpha": "Альфа", "lokr_factor": "Фактор LoKr", - "tlora_min_rank": "T-LoRA · минимальный ранг", - "tlora_alpha_rank_scale": "T-LoRA · связь шага и ранга", - "tlora_use_ortho": "T-LoRA · OrthoLoRA", + "tlora_min_rank": "Минимальный ранг (T-LoRA)", + "tlora_alpha_rank_scale": "Связь шага и ранга (T-LoRA)", + "tlora_use_ortho": "OrthoLoRA (T-LoRA)", "lora_dora": "DoRA", - "lora_rs": "Rank-stabilized", - "lora_dropout": "Dropout адаптера", - "lora_rank_dropout": "Dropout ранга", - "lora_module_dropout": "Dropout модуля", + "lora_rs": "rs-LoRA", + "lora_dropout": "Выбрасывание адаптера", + "lora_rank_dropout": "Выбрасывание ранга", + "lora_module_dropout": "Выбрасывание модуля", "lora_reg_dims": "Поблочные ранги", "epochs": "Эпох", "max_steps": "Предел шагов", "batch_size": "Размер батча", "grad_checkpoint": "Чекпоинт градиента", "grad_accum": "Накопление градиента", - "learning_rate": "Learning rate", - "lr_scheduler": "Расписание lr", + "learning_rate": "Скорость обучения", + "lr_scheduler": "Расписание скорости обучения", "lr_scheduler_t0": "Период рестарта", "lr_scheduler_t_mult": "Множитель периода", - "lr_scheduler_eta_min": "Нижний предел lr", + "lr_scheduler_eta_min": "Нижний предел скорости обучения", "lr_scheduler_warmup_steps": "Шагов прогрева", "lr_scheduler_cycle_count": "Число циклов", "lr_scheduler_cycle_max_lr": "Максимум lr циклов", @@ -1387,14 +1391,14 @@ "lr_scheduler_decay_start_ratio": "Начало финального спада", "lr_scheduler_decay_min_lr": "Lr в конце спада", "optimizer_type": "Оптимизатор", - "prodigy_d_coef": "Prodigy · множитель d", - "prodigy_safeguard_warmup": "Prodigy · защита разогрева", + "prodigy_d_coef": "Множитель d (Prodigy)", + "prodigy_safeguard_warmup": "Защита разогрева (Prodigy)", "came_beta1": "CAME β1", "came_beta2": "CAME β2", "came_beta3": "CAME β3", "came_eps1": "CAME eps1", "came_eps2": "CAME eps2", - "came_clip_threshold": "CAME · порог обрезки", + "came_clip_threshold": "Порог обрезки (CAME)", "lion_beta1": "Lion β1", "lion_beta2": "Lion β2", "automagic_min_lr": "Минимальный lr", @@ -1402,87 +1406,87 @@ "automagic_lr_bump": "Шаг подстройки", "automagic_beta2": "Automagic β2", "automagic_eps": "Automagic eps", - "automagic_clip_threshold": "Automagic · порог обрезки", + "automagic_clip_threshold": "Порог обрезки (Automagic)", "automagic_agreement_threshold": "Порог совпадения знаков", - "ppsf_d_coef": "PPSF · множитель d", - "ppsf_prodigy_steps": "PPSF · заморозка d после", + "ppsf_d_coef": "Множитель d (PPSF)", + "ppsf_prodigy_steps": "Заморозка d после (PPSF)", "ppsf_beta1": "PPSF β1", "ppsf_beta2": "PPSF β2", - "ppsf_split_groups": "PPSF · d по группам", - "ppsf_split_groups_mean": "PPSF · среднее d по группам", - "ppsf_use_speed": "PPSF · режим speed", - "ppsf_fused_back_pass": "PPSF · слияние с обратным проходом", - "ppsf_use_stableadamw": "PPSF · stable-AdamW", + "ppsf_split_groups": "d по группам (PPSF)", + "ppsf_split_groups_mean": "Среднее d по группам (PPSF)", + "ppsf_use_speed": "Режим speed (PPSF)", + "ppsf_fused_back_pass": "Слияние с обратным проходом (PPSF)", + "ppsf_use_stableadamw": "stable-AdamW (PPSF)", "soap_beta1": "SOAP β1", "soap_beta2": "SOAP β2", - "soap_precondition_frequency": "SOAP · частота предобусловливания", - "soap_max_precond_dim": "SOAP · предел размерности", - "soap_shampoo_beta": "SOAP · β Shampoo", - "soap_precond_in_state": "SOAP · предобусловливатель в состоянии", - "soap_sf_weight_lr_power": "Schedule-Free · степень веса", - "soap_sf_r": "Schedule-Free · r", - "soap_sf_warmup_steps": "Schedule-Free · разогрев", + "soap_precondition_frequency": "Частота предобусловливания (SOAP)", + "soap_max_precond_dim": "Предел размерности (SOAP)", + "soap_shampoo_beta": "β Shampoo (SOAP)", + "soap_precond_in_state": "Предобусловливатель в состоянии (SOAP)", + "soap_sf_weight_lr_power": "Степень веса (Schedule-Free)", + "soap_sf_r": "r (Schedule-Free)", + "soap_sf_warmup_steps": "Разогрев (Schedule-Free)", "ema_enabled": "EMA весов", - "ema_decay": "EMA · затухание", - "ema_start_ratio": "EMA · начало", + "ema_decay": "Затухание EMA", + "ema_start_ratio": "Начало EMA", "weight_decay": "Затухание весов", "kv_trim": "Обрезка KV", "vae_tiling": "Тайлинг VAE", "noise_enhancement_type": "Усиление шума", "noise_offset": "Смещение шума", "pyramid_noise_iters": "Уровней пирамидального шума", - "pyramid_noise_discount": "Пирамидальный шум · затухание", + "pyramid_noise_discount": "Затухание пирамидального шума", "timestep_sampling": "Схема выборки", "timestep_shift": "Сдвиг распределения", "timestep_mix_low_prob": "Доля смещённого края", "timestep_schedule_shift": "Сдвиг расписания", - "style_snr_mean": "Style SNR · центр", - "style_snr_sigma": "Style SNR · ширина", - "dual_peak_peak1_position": "Пик 1 · положение", - "dual_peak_peak1_width": "Пик 1 · ширина", - "dual_peak_peak1_weight": "Пик 1 · вес", - "dual_peak_peak2_position": "Пик 2 · положение", - "dual_peak_peak2_width": "Пик 2 · ширина", - "dual_peak_peak2_weight": "Пик 2 · вес", - "dual_peak_background_mean": "Фон · центр", - "dual_peak_background_width": "Фон · ширина", - "dual_peak_background_weight": "Фон · вес", + "style_snr_mean": "Центр Style SNR", + "style_snr_sigma": "Ширина Style SNR", + "dual_peak_peak1_position": "Положение пика 1", + "dual_peak_peak1_width": "Ширина пика 1", + "dual_peak_peak1_weight": "Вес пика 1", + "dual_peak_peak2_position": "Положение пика 2", + "dual_peak_peak2_width": "Ширина пика 2", + "dual_peak_peak2_weight": "Вес пика 2", + "dual_peak_background_mean": "Центр фона", + "dual_peak_background_width": "Ширина фона", + "dual_peak_background_weight": "Вес фона", "dual_peak_uniform_weight": "Равномерная доля", "timestep_shift_resolution_aware": "Сдвиг по разрешению", "infonoise_enabled": "InfoNoise", - "infonoise_K": "InfoNoise · корзин", - "infonoise_N_warm": "InfoNoise · шагов разогрева", - "infonoise_M": "InfoNoise · период пересчёта", - "infonoise_B": "InfoNoise · очередь на корзину", - "infonoise_beta": "InfoNoise · β", - "infonoise_N_min": "InfoNoise · минимум сэмплов", - "infonoise_gate_pivot_c": "InfoNoise · перегиб ворот", + "infonoise_K": "Корзин (InfoNoise)", + "infonoise_N_warm": "Шагов разогрева (InfoNoise)", + "infonoise_M": "Период пересчёта (InfoNoise)", + "infonoise_B": "Очередь на корзину (InfoNoise)", + "infonoise_beta": "β (InfoNoise)", + "infonoise_N_min": "Минимум сэмплов (InfoNoise)", + "infonoise_gate_pivot_c": "Перегиб ворот (InfoNoise)", "loss_type": "Тип loss", - "huber_c": "Huber · δ", + "huber_c": "δ (Huber)", "loss_weighting": "Взвешивание loss", "masked_loss": "Masked loss", "min_snr_gamma": "Min-SNR γ", "weight_cap_ratio": "Предел разброса весов", - "detail_inv_t_min": "detail_inv_t · минимум", - "detail_inv_t_max": "detail_inv_t · максимум", + "detail_inv_t_min": "Минимум detail_inv_t", + "detail_inv_t_max": "Максимум detail_inv_t", "leap_enabled": "LeapAlign", - "leap_ratio": "Leap · доля шагов", - "leap_variant": "Leap · вариант", - "leap_activation_k": "Leap · активный набор K", - "leap_nested_grad_coe": "Leap · вложенный градиент", - "leap_min_gap": "Leap · минимальный разрыв", + "leap_ratio": "Доля шагов (Leap)", + "leap_variant": "Вариант (Leap)", + "leap_activation_k": "Активный набор K (Leap)", + "leap_nested_grad_coe": "Вложенный градиент (Leap)", + "leap_min_gap": "Минимальный разрыв (Leap)", "leap_traj_sim_weighting": "Взвешивание траекторий Leap", - "leap_traj_sim_min": "Leap · нижний предел τ", - "dop_enabled": "DOP · дифференциальное сохранение вывода", + "leap_traj_sim_min": "Нижний предел τ (Leap)", + "dop_enabled": "Сохранение вывода (DOP)", "dop_weight": "Вес сохраняющей ветки", "dop_ratio": "Доля шагов с DOP", - "sra_enabled": "SRA · выравнивание представления", - "sra_block": "SRA · блок", - "sra_weight": "SRA · вес", - "sra_normalize": "SRA · нормировка", - "sra_decay_type": "SRA · затухание", - "sra_decay_start_ratio": "SRA · начало затухания", - "sra_decay_end_ratio": "SRA · конец затухания", + "sra_enabled": "Выравнивание представления (SRA)", + "sra_block": "Блок (SRA)", + "sra_weight": "Вес (SRA)", + "sra_normalize": "Нормировка (SRA)", + "sra_decay_type": "Затухание (SRA)", + "sra_decay_start_ratio": "Начало затухания (SRA)", + "sra_decay_end_ratio": "Конец затухания (SRA)", "grad_clip_max_norm": "Предельная норма градиента", "mixed_precision": "Точность", "attention_backend": "Бэкенд внимания", @@ -1516,6 +1520,23 @@ "fieldCount_few": "{{count}} поля", "fieldCount_many": "{{count}} полей", "fieldCount_other": "{{count}} поля", + "subgroups": { + "loraMain": "Параметры сети", + "loraDropout": "Параметры выбрасывания", + "loraExtra": "Дополнительные параметры сети", + "loraBlocks": "Поблочные ранги", + "datasetMain": "Настройки датасета", + "regSet": "Регуляризационный сет", + "trainMain": "Основные параметры", + "trainLr": "Скорость обучения и оптимизатор", + "trainEma": "Настройки EMA", + "lossMain": "Функция потерь", + "lossDop": "Настройки DOP", + "sysMain": "Основные параметры", + "sysOpt": "Параметры оптимизации", + "outMain": "Вывод и сохранение", + "outResume": "Продолжить тренировку" + }, "groups": { "model": "Пути моделей", "dataset": "Датасет", @@ -1537,10 +1558,10 @@ "timestep_sampling": "выборку ведёт InfoNoise", "timestep_shift": "выборку ведёт InfoNoise", "noise_enhancement_type": "мьютекс с InfoNoise", - "loss_weighting": "мьютекс с InfoNoise · только none", - "loss_type": "мьютекс с InfoNoise или Leap · только mse", + "loss_weighting": "Несовместимо с InfoNoise, только none", + "loss_type": "Несовместимо с InfoNoise и Leap, только mse", "masked_loss": "не работает с Leap и упаковкой NaViT", - "timestep_schedule_shift": "мьютекс с InfoNoise · только 1.0", + "timestep_schedule_shift": "Несовместимо с InfoNoise, только 1.0", "infonoise_enabled": "сначала верните усиление шума, взвешивание, тип loss и сдвиг расписания к значениям по умолчанию" }, "altDescriptions": { @@ -1597,14 +1618,14 @@ "previousSoupsSub": "Хранятся в soup/", "compatBadge": "одинаковые сеть, rank и база", "potTitle": "В кастрюле", - "potSubAverage_one": "{{count}} чекпоинт · веса нормируются к 100%", - "potSubAverage_few": "{{count}} чекпоинта · веса нормируются к 100%", - "potSubAverage_many": "{{count}} чекпоинтов · веса нормируются к 100%", - "potSubAverage_other": "{{count}} чекпоинта · веса нормируются к 100%", - "potSubSum_one": "{{count}} чекпоинт · веса складываются как есть", - "potSubSum_few": "{{count}} чекпоинта · веса складываются как есть", - "potSubSum_many": "{{count}} чекпоинтов · веса складываются как есть", - "potSubSum_other": "{{count}} чекпоинта · веса складываются как есть", + "potSubAverage_one": "{{count}} чекпоинт, веса нормируются к 100%", + "potSubAverage_few": "{{count}} чекпоинта, веса нормируются к 100%", + "potSubAverage_many": "{{count}} чекпоинтов, веса нормируются к 100%", + "potSubAverage_other": "{{count}} чекпоинта, веса нормируются к 100%", + "potSubSum_one": "{{count}} чекпоинт, веса складываются как есть", + "potSubSum_few": "{{count}} чекпоинта, веса складываются как есть", + "potSubSum_many": "{{count}} чекпоинтов, веса складываются как есть", + "potSubSum_other": "{{count}} чекпоинта, веса складываются как есть", "dropHint": "Перетащите .safetensors сюда", "pickFile": "Выбрать файл", "sumAverage": "Σ весов = 100%", @@ -1717,7 +1738,7 @@ "cancelRequested": "Запрошена отмена #{{id}}", "generating": "Генерация…", "startGenerate": "Сгенерировать", - "startGenerateCount": "Сгенерировать · {{n}} изобр.", + "startGenerateCount": "Сгенерировать {{n}} изобр.", "loraHint": "Выберите версию из проекта и настройте вес ползунком", "prompts": "Промпт", "pickFromDatasetTitle": "Взять капшен из тренировочного набора как промпт", @@ -1737,7 +1758,7 @@ "seed": "Seed", "seedHint": "Конкретное число — повторяемый результат; 0 — каждый раз случайный", "randomSeed": "Случайный", - "sizeNote": "кратно 64 · {{mp}} Мпикс", + "sizeNote": "Кратно 64, {{mp}} Мпикс", "swapSize": "↔ поменять", "baseModel": "Базовая модель", "baseModelHint": "По умолчанию — основная модель из настроек; можно выбрать другую скачанную версию для этой генерации", @@ -1760,7 +1781,7 @@ "stepsN_few": "{{count}} шага", "stepsN_many": "{{count}} шагов", "stepsN_other": "{{count}} шага", - "doneIn": "готово · {{time}}", + "doneIn": "готово за {{time}}", "secShort": "{{n}} с", "originalReleasedCoverOnly": "Оригинал выгружен из памяти, осталась только обложка", "originalReleasedThumbOnly": "Оригинал выгружен из памяти, осталась только миниатюра", @@ -1859,19 +1880,19 @@ "exportFailed": "Ошибка: {{error}}", "xyGridCount": "{{x}}{{axis}} = {{n}} изобр.", "generatedCount": "Готово {{n}}", - "xyGridHelp": "Колесо — масштаб · перетаскивание — сдвиг · Ctrl+клик — выбрать · двойной клик — во весь экран", + "xyGridHelp": "Колесо — масштаб, перетаскивание — сдвиг, Ctrl+клик — выбрать, двойной клик — во весь экран", "resetZoom": "Сбросить масштаб", "exportPngTitle": "Собрать всю XY-матрицу с подписями осей в один PNG", "exporting": "Экспорт…", "exportPng": "Экспорт PNG", - "xyCellTooltip": "{{label}} · двойной клик — во весь экран · Ctrl+клик — выбрать", + "xyCellTooltip": "{{label}}. Двойной клик — во весь экран, Ctrl+клик — выбрать", "backToGrid": "← К сетке", "backToGridClear": "← К сетке / снять выбор", "selectedSamplesUnavailable": "Выбранные изображения недоступны — выберите 2 в сетке заново", "doubleClickFullscreen": "Двойной клик — во весь экран", "doubleClickFullscreenTitle": "Двойной клик — во весь экран", - "compareShortcutHint": "← / → переключить {{direction}} · ESC — закрыть", - "fullscreenNavPrefix": "{{dirs}} — переключить · ", + "compareShortcutHint": "← / → переключить {{direction}}, Esc — закрыть", + "fullscreenNavPrefix": "{{dirs}} — переключить, ", "fullscreenCloseHint": "ESC или клик по фону — закрыть", "prevRow": "Предыдущая ячейка", "nextRow": "Следующая ячейка", @@ -1885,7 +1906,7 @@ "phaseVae": "Декодирование VAE", "progressPreparingShort": "Подготовка…", "progressSampling": "Сэмплинг: шаг {{step}} / {{total}}", - "progressBatch": "Изображение {{current}} / {{total}} · {{detail}}", + "progressBatch": "Изображение {{current}} из {{total}}: {{detail}}", "totalProgress": "Всего {{pct}}%", "addValue": "+ Добавить", "prunedCount": "Удалено записей: {{n}}", @@ -1927,7 +1948,7 @@ "summaryCount_few": "{{count}} задачи", "summaryCount_many": "{{count}} задач", "summaryCount_other": "{{count}} задачи", - "summaryBreakdown": "Выполняется: {{running}} · ожидает: {{waiting}}", + "summaryBreakdown": "Выполняется {{running}}, ожидает {{waiting}}", "summaryFinish": "Очередь завершится", "summaryTaskFinish": "Примерное окончание", "summaryUnknown": "Время уточняется", @@ -1968,7 +1989,7 @@ "remaining": "осталось ~{{time}}", "nothingRunning": "ничего не выполняется", "trainTitle": "Задачи тренировки", - "trainSub": "Перетаскивание меняет приоритет · правый клик — заметка", + "trainSub": "Перетаскивайте, чтобы поменять приоритет. Правый клик — заметка", "filter": { "active": "Активные", "all": "Все", @@ -1976,9 +1997,9 @@ }, "noActive": "Активных задач нет", "showMore": "Показать ещё {{n}} (осталось {{total}})", - "waitingSlot": "Ждёт слот · впереди {{n}}", - "scheduledAt": "Запланирована · {{time}}", - "doneAt": "Готово · {{time}}", + "waitingSlot": "Ждёт слот, впереди {{n}}", + "scheduledAt": "Запланирована на {{time}}", + "doneAt": "Готово в {{time}}", "until": "до {{time}}", "elapsed": "идёт", "stepN": "шаг {{n}}", @@ -2016,7 +2037,7 @@ "hm": "{{h}} ч {{m}} м" }, "titleJobs": "Очередь задач данных", - "descriptionJobs": "Скачивание / тегирование / препроцесс / сбор reg / оценка · отдельная последовательная линия рядом с очередью GPU", + "descriptionJobs": "Скачивание, тегирование, препроцесс, сбор reg и оценка идут своей очередью, отдельно от GPU", "clearDone": "Очистить завершённые", "cancelCurrent": "Отменить текущую задачу", "cancelHint": "Отправить сигнал отмены выполняющейся задаче", @@ -2041,7 +2062,7 @@ "resumeTerminalHint": "Продолжить с последнего автосохранения в конце эпохи (конфиг заморожен)", "cancelPaused": "Отменить задачу", "cancelPausedHint": "Остановить задачу (точка восстановления сохранится, можно продолжить позже)", - "pausedAtStep": "Пауза на шаге {{step}} · {{time}}", + "pausedAtStep": "Пауза на шаге {{step}}, {{time}}", "holdQueue": "Удержать очередь", "releaseQueue": "Снять удержание", "heldBanner": "Очередь удержана: ожидающие задачи не запустятся сами", @@ -2180,7 +2201,7 @@ "pausedWhen": "на паузе" }, "dataset": { - "eyebrow": "Шаг · датасет проекта", + "eyebrow": "Датасет проекта", "subtitle": "Сбор с booru + загрузка своих файлов. Общий для всех версий проекта.", "nextCurate": "Дальше: отбор в train", "booruTitle": "Сбор с booru", @@ -2204,13 +2225,13 @@ "matchesUnknown": "неизвестно", "taskBadge": "задача #{{id}}", "downloadN": "Скачать {{n}}", - "uploadSub": "png · jpg · webp · bmp · gif · .zip (распаковывается сам)", + "uploadSub": "png, jpg, webp, bmp, gif или .zip (распакуется сам)", "dropHint": "Кликните, чтобы выбрать, или перетащите файлы", - "filesPicked_one": "{{count}} файл выбран · {{size}}", - "filesPicked_few": "{{count}} файла выбрано · {{size}}", - "filesPicked_many": "{{count}} файлов выбрано · {{size}}", - "filesPicked_other": "{{count}} файла выбрано · {{size}}", - "uploadPct": "{{pct}}% · загрузка", + "filesPicked_one": "Выбран {{count}} файл, {{size}}", + "filesPicked_few": "Выбрано {{count}} файла, {{size}}", + "filesPicked_many": "Выбрано {{count}} файлов, {{size}}", + "filesPicked_other": "Выбрано {{count}} файла, {{size}}", + "uploadPct": "Загрузка {{pct}}%", "processing": "обработка", "statsTitle": "Статистика загрузок", "statsSub": "Каталог download/ проекта", @@ -2251,7 +2272,7 @@ "canceled": "Отменено", "confirmDelete": "Удалить {{n}} изобр. из download/ (вместе с метаданными капшенов)?\nЭто нельзя отменить.", "deletedToast": "Удалено изображений: {{deleted}}", - "deletedSkipped": " · пропущено {{skipped}} (не найдены)", + "deletedSkipped": ", пропущено {{skipped}} (не найдены)", "sectionTitle": "Скачано", "deleteTitle": "Удалить выбранные изображения и метаданные капшенов", "deleting": "Удаление…", @@ -2264,7 +2285,7 @@ "uploadCount": "Загрузить {{n}}", "uploadFromPath": "Выбрать файл на сервере", "uploadAdded": "Добавлено изображений: {{n}}", - "uploadSkippedSuffix": " · пропущено {{skipped}}", + "uploadSkippedSuffix": ", пропущено {{skipped}}", "allSucceeded": "Всё загружено, ничего не пропущено.", "added": "Добавлено", "skipped": "Пропущено", @@ -2283,7 +2304,7 @@ "startCount": "Начать {{n}}", "clickOrDrop": "Нажмите, чтобы выбрать, или перетащите сюда", "acceptedFormats": "· png / jpg / webp / bmp / gif / .zip (распаковывается автоматически)", - "filesSelected": "выбрано {{n}} · {{mb}} МБ", + "filesSelected": "выбрано {{n}}, {{mb}} МБ", "uploadFromPathHint": "Импортируйте изображение или zip, видимые серверу Studio.", "statsTitle": "Статистика скачивания", "statsTotal": "Всего", @@ -2311,7 +2332,7 @@ "addNToVal": "Добавить {{n}} в validation", "newFolderPrompt": "Имя новой группы (например 5_concept)", "removeFromSet": "Убрать из набора", - "removeFromSetN": "Убрать из набора · {{n}}", + "removeFromSetN": "Убрать из набора ({{n}})", "moveToVal": "В validation", "moveToValTitle": "Перенести выбранные из train в отложенный validation", "moveToValOk": "Перенести", @@ -2332,7 +2353,7 @@ "sortLabel": "Сортировка", "sortTitle": "Сортировка действует на обе сетки", "downloadPanelTitle": "Датасет — все изображения", - "downloadSubtitle": "{{unused}} не использовано / {{total}} всего · {{sel}} выбрано", + "downloadSubtitle": "{{unused}} из {{total}} не использовано, выбрано {{sel}}", "selectAll": "Выбрать все", "deselect": "Снять", "copyToBtn": "→ Добавить {{n}} → {{folder}}", @@ -2340,7 +2361,7 @@ "noFolderTitle": "Сначала выберите группу train-набора справа", "downloadEmptyHint": "Все изображения датасета уже использованы или ещё ничего не загружено", "trainPanelTitle": "Train-набор — текущая версия", - "trainSubtitle": "{{total}} изобр. · групп: {{folders}} · {{sel}} выбрано", + "trainSubtitle": "{{total}} изобр., групп: {{folders}}, выбрано {{sel}}", "newFolderPlaceholder": "+ Новая: 5_concept", "createFolderBtn": "Создать", "removeNBtn": "← Убрать {{n}}", @@ -2353,8 +2374,8 @@ "folderActiveTitle": "Открыта сейчас (и сюда добавляются изображения)", "folderSwitchTitle": "Открыть и сделать целью добавления", "altHoverClose": "Отпустите Alt, чтобы закрыть", - "previewHintLeft": "←/→ листать · Enter/Space — добавить в train-набор", - "previewHintRight": "←/→ листать · Delete/Backspace — убрать из train-набора", + "previewHintLeft": "←/→ листать, Enter или пробел — добавить в train-набор", + "previewHintRight": "←/→ листать, Delete или Backspace — убрать из train-набора", "renamedToast": "{{from}} → {{to}}", "sourceLabelDownload": "Датасет", "sourceLabelTrain": "Train-набор / {{folder}}", @@ -2363,12 +2384,12 @@ "copyToValBtn": "→ В validation {{n}}", "copyToValTitle": "Добавить в отложенный validation-набор", "valPanelTitle": "Validation-набор — отложенный", - "valSubtitle": "{{total}} изобр. · {{sel}} выбрано", + "valSubtitle": "{{total}} изобр., выбрано {{sel}}", "valHint": "Отложенный validation-набор: на этих изображениях не тренируем, по ним после тренировки считаются метрики обобщения CLIP-I / DINO-I. Включите validation-оценку в конфиге тренировки, чтобы она заработала; если задана доля разбиения, набор дополняется поверх ваших ручных выборов.", "valEmpty": "Validation-изображений пока нет — добавьте их из датасета слева" }, "ppFrame": { - "eyebrow": "Шаг 2 · необязательно", + "eyebrow": "Шаг 2, необязательный", "title": "Инструменты препроцесса", "subtitle": "Апскейл / кроп / inpaint / дубликаты — работают по train-набору версии", "next": "Дальше: теги", @@ -2377,14 +2398,14 @@ "restoredN": "Восстановлено изображений: {{n}}", "logLabel": "Журнал препроцесса", "sumUpscale": "{{n}} / {{total}} обработано", - "sumCrop": "бакеты ARB · пропорций: {{n}}", + "sumCrop": "Бакеты ARB, пропорций: {{n}}", "sumInpaint": "маски: {{n}} изобр.", "sumDedupe": "убрано как дубли: {{n}}", "fieldModel": "Модель апскейлера", "fieldDevice": "Устройство", "fieldTarget": "Целевой размер", "fieldTile": "Тайл", - "modelReadySize": "скачана · {{mb}} МБ", + "modelReadySize": "Скачана, {{mb}} МБ", "modelMissing": "не скачана", "targetCustom": "Свой", "targetOff": "Выкл", @@ -2392,10 +2413,10 @@ "targetHintOff": "Выкл: каждое изображение идёт через 4× модель", "targetOffWarn": "«Выкл» гонит каждую картинку через 4× модель — на диске это дорого.", "diskProcessed": "обработано {{n}} изобр. на диске", - "runUpscale": "Запустить апскейл · {{n}}", - "runSelected": "Апскейл выбранных · {{n}}", - "clearN": "Снять · {{n}}", - "jobBadge": "задача #{{id}} · {{n}} / {{total}}", + "runUpscale": "Запустить апскейл ({{n}})", + "runSelected": "Апскейл выбранных ({{n}})", + "clearN": "Снять ({{n}})", + "jobBadge": "Задача #{{id}}: {{n}} из {{total}}", "previewProcessed": "✓ обработано", "previewPending": "⊘ не обработано", "dupHow": "Как искать", @@ -2409,14 +2430,14 @@ "dupAllGroups": "Все группы", "dupExact": "Точные", "dupSimilar": "Похожие", - "dupChecked": "проверено {{n}} из {{total}} · {{sel}} файлов под удаление", + "dupChecked": "Проверено {{n}} из {{total}}, к удалению {{sel}}", "dupEmpty": "Выберите режим слева и нажмите «Найти дубликаты» — группы появятся здесь.", "dupGroup": "группа {{n}}", "dupSimilarity": "схожесть {{n}}%", - "dupFiles_one": "{{count}} файл · оставить один", - "dupFiles_few": "{{count}} файла · оставить один", - "dupFiles_many": "{{count}} файлов · оставить один", - "dupFiles_other": "{{count}} файла · оставить один", + "dupFiles_one": "{{count}} файл, оставить один", + "dupFiles_few": "{{count}} файла, оставить один", + "dupFiles_many": "{{count}} файлов, оставить один", + "dupFiles_other": "{{count}} файла, оставить один", "dupKeepBest": "Оставить лучший", "dupKeep": "оставить" }, @@ -2430,7 +2451,7 @@ "confirmRestore": "Восстановить {{n}} обработанных изображений (копии препроцесса удалятся, статус вернётся в «ожидает»)?", "confirmRestoreOk": "Восстановить", "restoredToast": "Восстановлено изображений: {{restored}}", - "restoredSkipped": " · пропущено {{skipped}}", + "restoredSkipped": ", пропущено {{skipped}}", "deviceAuto": "Авто (предпочесть CUDA)", "targetCustomLabel": "Свой", "targetOffLabel": "Выкл (выход 4×)", @@ -2448,8 +2469,8 @@ "folderAll": "Все папки", "edgePlaceholder": "сторона, px", "modelLabel": "Модель", - "notDownloaded": " · не скачана", - "customModel": " · своя", + "notDownloaded": " (не скачана)", + "customModel": " (своя)", "deviceLabel": "Устройство", "upscaleSelectedHint": "Выберите изображения в списке", "upscaleSelected": "Апскейл выбранных {{n}}", @@ -2524,7 +2545,7 @@ "duplicates": { "title": "Дубликаты и варианты кадра", "subtitle": "Найти группы похожих изображений, просмотреть и пометить лишние", - "summary": "{{groups}} групп · {{candidates}} кандидатов / {{total}} изобр.", + "summary": "{{groups}} групп, {{candidates}} кандидатов из {{total}} изобр.", "panelTitle": "Параметры поиска дубликатов", "scope": "Что искать", "scopeStrict": "Точные копии", @@ -2546,8 +2567,8 @@ "confirmApply": "Пометить {{n}} выбранных кандидатов как убранные дубли?\nУбедитесь, что просмотрели группы. Файлы в download/ не перемещаются и не удаляются; дальше препроцесс, отбор и тренировка их пропустят.", "applyOk": "Убрать кандидатов", "appliedToast": "Помечено как убранные: {{n}}", - "appliedSkipped": " · пропущено {{n}}", - "appliedMissing": " · не найдено {{n}}", + "appliedSkipped": ", пропущено {{n}}", + "appliedMissing": ", не найдено {{n}}", "keepSuggested": "Предложено оставить", "keep": "Оставить", "groupCandidates": "{{n}} изобр.", @@ -2555,7 +2576,7 @@ "selectedRemove": "Убрать", "removeCandidate": "Пометить на удаление", "restoreCandidate": "Не удалять", - "previewHint": "←/→ листать кандидатов · выберите, что убрать", + "previewHint": "←/→ листать кандидатов и выбирать, что убрать", "logTitle": "Журнал поиска", "logStarted": "Ищем кандидатов в дубликаты…", "statsTitle": "Итоги разбора", @@ -2649,7 +2670,7 @@ "emptyWorkspace": "Рабочая область пуста,", "goToOverview": "перейти в обзор", "canvasLoading": "Загрузка изображения в полном размере…", - "canvasHint": "Колесо — масштаб · пробел + перетаскивание — сдвиг · Alt-клик — пипетка", + "canvasHint": "Колесо — масштаб, пробел с перетаскиванием — сдвиг, Alt+клик — пипетка", "zoomFit": "Вписать", "brushColor": "Цвет", "colorWheel": "Цветовой круг (с пипеткой и вводом RGB)", @@ -2696,7 +2717,7 @@ "imagesSub": "{{n}} в train", "withTag": "{{n}} с тегом «{{tag}}»", "folderLabel": "Папка", - "allFolders": "Все папки · {{n}}", + "allFolders": "Все папки ({{n}})", "noActive": "Нет изображения", "noActiveHint": "Выберите изображение в сетке" }, @@ -2706,10 +2727,10 @@ "modeText": "Текст", "modeLabel": "Вид капшена", "tagCount": "{{n}} тегов", - "captionCount_one": "Капшен · {{count}} тег", - "captionCount_few": "Капшен · {{count}} тега", - "captionCount_many": "Капшен · {{count}} тегов", - "captionCount_other": "Капшен · {{count}} тега", + "captionCount_one": "Капшен ({{count}} тег)", + "captionCount_few": "Капшен ({{count}} тега)", + "captionCount_many": "Капшен ({{count}} тегов)", + "captionCount_other": "Капшен ({{count}} тега)", "empty": "Тегов пока нет", "addPlaceholder": "+ тег", "textPlaceholder": "Через запятую или с новой строки; применяется при выходе из поля", @@ -2751,7 +2772,7 @@ "opLabelDedupe": "Убрать повторы" }, "reg": { - "eyebrow": "Шаг 4 · необязательно", + "eyebrow": "Шаг 4, необязательный", "skipStep": "Пропустить шаг", "moreActions": "Ещё: удалить набор", "next": "Дальше: тренировка", @@ -2785,7 +2806,7 @@ "captionFailed": "Не удалось загрузить: {{error}}", "captionLoading": "Загрузка…", "captionTitle": "Капшен", - "captionNote": "капшен reg · без триггера", + "captionNote": "Капшен reg без триггера", "collected": "{{n}} / {{target}} собрано", "confirmFullRun": "Сборка заново удалит текущие {{n}} изобр. reg-набора и соберёт его с нуля. Продолжить?", "confirmDedup": "Убрать дубли в reg-наборе? В каждой группе останется одно изображение, остальные удалятся (их booru-ID не будут скачаны при добивке). Действие необратимо.", @@ -2812,7 +2833,7 @@ "excludeNoTags": "В train пока нет статистики тегов. Свои теги можно добавить через «+ тег».", "excludePlaceholder": "+ тег", "excludeTitle": "Исключить теги", - "excludeTrainTitle": "Частые теги train · нажмите, чтобы исключить", + "excludeTrainTitle": "Частые теги train, нажмите, чтобы исключить", "excludedN_one": "{{count}} тег", "excludedN_few": "{{count}} тега", "excludedN_many": "{{count}} тегов", @@ -2827,13 +2848,13 @@ "grpSampling": "Сэмплинг", "heightLabel": "Высота", "imageActions": "Действия с изображением", - "imagesFooter": "{{n}} изобр. · папок: {{folders}}", + "imagesFooter": "{{n}} изобр., папок: {{folders}}", "invalidKey": "исключены из запроса", "invalidNone": "все теги нашлись", "kvFolder": "Папка", "kvSize": "Размер", "kvSource": "Источник", - "latestRun": "задача #{{id}} · {{status}}", + "latestRun": "Задача #{{id}}: {{status}}", "less": "Меньше", "more": "Больше", "loadFailed": "Не удалось загрузить состояние reg: {{error}}", @@ -2890,16 +2911,16 @@ "statusCellInvalidTags": "INVALID TAGS", "statusCellLatest": "LATEST", "statusCellSet": "REG SET", - "statusCellSourceTag": "SOURCE · TAGGER", + "statusCellSourceTag": "Источник и тэггер", "statusLoading": "Загрузка…", "statusNotBuilt": "набор ещё не собран", "statusTaggerOff": "без автотегов", "stepsLabel": "Шаги", - "structFlat": "1_data/ · одна папка", - "structMirror_one": "как в train · {{count}} папка", - "structMirror_few": "как в train · {{count}} папки", - "structMirror_many": "как в train · {{count}} папок", - "structMirror_other": "как в train · {{count}} папки", + "structFlat": "1_data/, одна папка", + "structMirror_one": "Как в train, {{count}} папка", + "structMirror_few": "Как в train, {{count}} папки", + "structMirror_many": "Как в train, {{count}} папок", + "structMirror_other": "Как в train, {{count}} папки", "tabGenerate": "Генерация", "tabImages": "Изображения", "taggerUnknown": "тэггер неизвестен", @@ -2922,11 +2943,11 @@ "grpTagCommon": "Общее", "grpExpand": "Развернуть", "grpCollapse": "Свернуть", - "grpSamplingMeta": "Шаги · CFG · seed", + "grpSamplingMeta": "Шаги, CFG, seed", "baseModelLabel": "Базовая модель", "baseModelHint": "По умолчанию — основная модель из настроек; чтобы генерировать reg-изображения на другой скачанной версии, переключите её здесь", "grpRegProcessing": "Обработка reg-набора", - "grpAdvancedMetaBooru": "Тэггер · дубли · постобработка", + "grpAdvancedMetaBooru": "Тэггер, дубли, постобработка", "modeHintAi": "Что делать с уже собранными изображениями при повторном запуске", "modeHintBooru": "Что делать с уже собранными изображениями при повторном сборе", "excludeMetaPrefix": "Исключено", @@ -3004,10 +3025,10 @@ "subtitle": "Глобальные шаблоны конфига. Версия проекта берёт копию — правки в ней не меняют пресет.", "headMenu": "Ещё: импорт, экспорт YAML", "allTitle": "Все пресеты", - "allSub_one": "{{count}} шаблон · клик открывает сводку справа", - "allSub_few": "{{count}} шаблона · клик открывает сводку справа", - "allSub_many": "{{count}} шаблонов · клик открывает сводку справа", - "allSub_other": "{{count}} шаблона · клик открывает сводку справа", + "allSub_one": "{{count}} шаблон", + "allSub_few": "{{count}} шаблона", + "allSub_many": "{{count}} шаблонов", + "allSub_other": "{{count}} шаблона", "colName": "Название", "colType": "Тип", "colRank": "Rank", @@ -3118,7 +3139,7 @@ "deletedFiles": "Удалено файлов: {{n}}", "deleteFailed": "Не удалось удалить: {{error}}", "compressing": "Упаковка…", - "downloadSelected": "Скачать выбранные ({{n}} · {{size}})", + "downloadSelected": "Скачать выбранные ({{n}}, {{size}})", "downloadAll": "Скачать всё", "exportingOutputs": "Экспорт…", "exportedToDataExports": "Экспортировано в data_exports: {{filename}} ({{path}})", @@ -3145,7 +3166,7 @@ "downloadFile": "Скачать", "folderOpened": "Открыто: {{path}}", "sourceLink": "Проект #{{projectId}} / v#{{versionId}}", - "eyebrow": "Задача · {{kind}}", + "eyebrow": "Задача {{kind}}", "startedAtShort": "запущена {{time}}", "runningFor": "идёт {{time}}", "queuedAtShort": "в очереди с {{time}}", @@ -3157,7 +3178,7 @@ "lossDelta": "{{pct}} за 200 шагов", "remaining": "Осталось", "finishAt": "финиш ≈ {{time}}", - "nowStep": "{{step}} · сейчас", + "nowStep": "{{step}}, сейчас", "curvesTitle": "Кривые обучения", "curvesSub": "Шаг 0 → {{total}}", "noCurves": "Кривые появятся после первых шагов", @@ -3169,11 +3190,11 @@ "noSamples": "Семплов пока нет", "propsTitle": "Свойства", "propsSub": "Снимок на момент запуска", - "idSource": "ID · источник", - "outputsSub_one": "{{count}} чекпоинт · {{size}}", - "outputsSub_few": "{{count}} чекпоинта · {{size}}", - "outputsSub_many": "{{count}} чекпоинтов · {{size}}", - "outputsSub_other": "{{count}} чекпоинта · {{size}}", + "idSource": "ID и источник", + "outputsSub_one": "{{count}} чекпоинт, {{size}}", + "outputsSub_few": "{{count}} чекпоинта, {{size}}", + "outputsSub_many": "{{count}} чекпоинтов, {{size}}", + "outputsSub_other": "{{count}} чекпоинта, {{size}}", "noCheckpoints": "Чекпоинтов пока нет", "downloadCheckpoints": "Скачать чекпоинты", "filesCount_one": "{{count}} файл", @@ -3191,7 +3212,7 @@ "evalScore": "Счёт по метрикам", "evalStillRunning": "{{list}} ещё считается", "evalTableTitle": "Метрики по чекпоинтам", - "evalTableSub": "Против validation-набора · Δ считается к чистой базовой модели", + "evalTableSub": "На validation-наборе, Δ к чистой базовой модели", "evalEmpty": "Метрик пока нет", "metricCol": "Метрика", "deltaCol": "Δ к базовой", @@ -3264,7 +3285,8 @@ "settingsSub": "Глобальные настройки", "presetItem": "Пресет тренировки", "projectItem": "Проект #{{id}}", - "generateSub": "Одиночно / XY-перебор" + "generateSub": "Одиночно / XY-перебор", + "graphSub": "Сравнение результатов обучения" }, "settings": { "sectionsCap": "Разделы", @@ -3322,8 +3344,8 @@ "officialWeights": "официальные веса", "modelRootSaved": "Корень моделей сохранён: {{path}}", "modelRootDefault": "Корень моделей сброшен по умолчанию", - "autoSyncPathsOn": "Вкл · fork берёт пути из настроек", - "autoSyncPathsOff": "Выкл · fork уважает пути из пресета", + "autoSyncPathsOn": "Вкл: форк берёт пути из настроек", + "autoSyncPathsOff": "Выкл: форк берёт пути из пресета", "trainingModelsOneClick": "Модели тренировки (загрузка в один клик)", "modelsRoot": "Корень моделей (models_root)", "savePathConfig": "Сохранить пути", @@ -3499,7 +3521,7 @@ "from": "Откуда", "to": "Куда", "totalLine": "{{files}} файлов, {{size}} всего", - "entryMeta": "{{files}} файлов · {{size}}", + "entryMeta": "{{files}} файлов, {{size}}", "keepOriginalNote": "Данные в исходном расположении сохраняются, не удаляются. После переноса перезапустите Studio, чтобы новое расположение вступило в силу.", "migrating": "Копирование…", "doneTitle": "Перенос завершён", @@ -3710,7 +3732,7 @@ "ramGuardOn": "Включено", "ramGuardOff": "Выключено" }, - "krea2TeCardTitle": "Krea 2 · текстовый энкодер Qwen3-VL", + "krea2TeCardTitle": "Текстовый энкодер Qwen3-VL для Krea 2", "deleteAsset": "Удалить", "deleteAssetTitle": "Удалить скачанные файлы (можно скачать заново в любой момент)", "teVariantSelected": "Текстовый энкодер переключён: {{name}}", @@ -3745,7 +3767,7 @@ "versionInfo3": "Активные задачи / незакоммиченные локальные изменения → предпроверка отклонит действие", "versionInfo4": "master показывает релизные теги; dev показывает ленту коммитов и позволяет переключиться на любой из них", "showDevChannel": "Показывать канал dev (сборка для разработки)", - "currentDevLocked": "Сейчас на dev · выключить нельзя", + "currentDevLocked": "Сейчас на dev, выключить нельзя", "updateLogTitle": "Лог последнего обновления", "close": "Закрыть", "onboardingReopenTitle": "Повторить первичную настройку", @@ -3778,11 +3800,11 @@ "preflightCheck": "Предпроверка", "checkingEllipsis": "Проверка…", "preflightFailedRetry": "Предпроверка не удалась — повторить", - "preflightInfo": "git reset --hard · остановка сервера · при необходимости pip/npm install · перезапуск · займёт примерно 1–3 минуты", + "preflightInfo": "git reset --hard, остановка сервера, при необходимости pip/npm install и перезапуск. Займёт 1–3 минуты", "cancel": "Отмена", "processing": "Обработка…", "confirmActionTo": "Подтвердить {{action}} → {{label}}", - "progressStarted": "Обновление запущено · сервер перезапускается…", + "progressStarted": "Обновление запущено, сервер перезапускается…", "progressWaitReload": "Страница подождёт и обновится автоматически", "progressDetail": "Текущий процесс отправил SIGINT и завершился; cli.py выполняет git reset / pip / npm / перезапуск. Прогресс в реальном времени недоступен, пока сервер не в сети. Фронтенд переподключится и обновится после перезапуска; ошибки отобразятся красным баннером на карточке master.", "viewAnnouncements": "Смотреть объявления", @@ -3793,7 +3815,7 @@ "kindRemoved": "Удалено", "kindDeprecated": "Устарело", "kindSecurity": "Безопасность", - "moreItems": "ещё {{count}} · ", + "moreItems": "ещё {{count}}, ", "detailContent": "Подробности ↗", "loadingEllipsis": "Загрузка…", "confirmUpdate": "Подтвердить обновление", @@ -3876,12 +3898,12 @@ "saveChannelFailed": "Не удалось сохранить настройку канала: {{error}}", "devAhead": "Локальная установка опережает dev HEAD (редкий случай; возможно, пре-релиз)", "loadFailedWithError": "Загрузка не удалась: {{error}}", - "stableConfirmUpdate": "Stable · Подтвердить обновление", - "stableUpdating": "Stable · Обновление", - "targetThisCommit": "{{label}} · Этот коммит", + "stableConfirmUpdate": "Подтвердите обновление Stable", + "stableUpdating": "Обновление Stable", + "targetThisCommit": "{{label}}, этот коммит", "switchToStable": "Переключиться на stable {{tag}}…", - "devConfirmSwitch": "Dev · Подтвердить переключение", - "devSwitching": "Dev · Переключение", + "devConfirmSwitch": "Подтвердите переход на Dev", + "devSwitching": "Переход на Dev", "llmPresetListHint": "Пресет объединяет подключение + промпт + параметры. Выберите один как глобальный по умолчанию; на странице тегирования можно временно выбрать другой для конкретного запуска.", "llmPresetSetDefault": "Сделать глобальным по умолчанию", "llmPresetEdit": "✎ Правка", @@ -3925,13 +3947,13 @@ } }, "llmMessages": { - "roleHintSystem": "Инструкция · обычно пишется один раз", + "roleHintSystem": "Инструкция, обычно пишется один раз", "roleHintUser": "few-shot пример / описание задачи", "roleHintAssistant": "ожидаемый ответ для few-shot", "addMessage": "+ Добавить сообщение", "dragHandle": "Перетащите, чтобы изменить порядок", "currentImage": "Текущее изображение", - "imageHint": "Сюда подставляется изображение во время тегирования · можно перетаскивать", + "imageHint": "Сюда подставляется изображение при тегировании, можно перетаскивать", "deleteMessage": "Удалить сообщение", "placeholderLabel": "Плейсхолдер: ", "imageReplaceHint": "Каждый раз заменяется тегируемым изображением", @@ -4291,12 +4313,12 @@ "saveAsCopy": "⎘ Сохранить как копию", "done": "Готово", "presetLabels": { - "styleJson": "Стилевая LoRA · JSON", - "generalJson": "Обычная LoRA · JSON", - "txtTags": "Список тегов · TXT", + "styleJson": "Стилевая LoRA (JSON)", + "generalJson": "Обычная LoRA (JSON)", + "txtTags": "Список тегов (TXT)", "joycaption": "JoyCaption (локальный vLLM)", - "assistJson": "Тэггер-помощник (локальный) · JSON", - "assistText": "Тэггер-помощник (локальный) · Текст" + "assistJson": "Тэггер-помощник, локальный (JSON)", + "assistText": "Тэггер-помощник, локальный (текст)" } }, "llmWorkspace": { @@ -4305,7 +4327,7 @@ "jsonCaption": "JSON-капшен", "newPreset": "+ Новый пресет", "resetBuiltinTitle": "Сбросить этот встроенный пресет к значению по умолчанию", - "builtinPrefix": "Встроенный · ", + "builtinPrefix": "Встроенный: ", "testConnection": "Проверить соединение", "promptTemplate": "Шаблон промпта", "output": "Вывод", @@ -4317,7 +4339,7 @@ "selectPreset": "Выберите пресет", "secretSavedPlaceholder": "Сохранён (скрыт); введите новое значение, чтобы заменить", "fetchingModels": "Загрузка…", - "maxSideHint": "px · изменение размера по длинной стороне", + "maxSideHint": "px, размер по длинной стороне", "modelPlaceholder": "gpt-4.1-mini / qwen-vl-max / имя локальной модели", "textCaption": "Текстовый капшен", "endpointStyle": "Тип эндпоинта", @@ -4327,7 +4349,376 @@ "deletePreset": "✕ Удалить", "builtin": "Встроенный", "preset": "Пресет", - "messageSummary": "{{n}} сообщений · перетащите, чтобы изменить порядок", + "messageSummary": "{{n}} сообщений, порядок меняется перетаскиванием", "advanced": "Дополнительные параметры" + }, + "graph": { + "eyebrow": "Инструменты", + "title": "График", + "subtitle": "Хранение и сравнение результатов обучения: что уже пробовала, что нет, и где результаты лучше.", + "stats": "{{total}} тренировок, {{withSamples}} с сэмплами, {{planned}} запланировано", + "welcomeTitle": "Создайте доску", + "welcomeBody": "У доски свои параметры и свои карточки тренировок. Удобно заводить по доске на стиль или на LoRA.", + "welcome1": "Создайте доску — в ней сразу будут привычные параметры (LR, оптимизатор, ранг…).", + "welcome2": "Добавляйте карточку на каждую тренировку: укажите параметры и перетащите семплы или возьмите тренировку из очереди.", + "welcome3": "В «Матрице» видно, какие сочетания ещё не пробовали, и оттуда же можно сразу запустить новую тренировку.", + "newBoard": "Новая доска", + "newBoardSub": "У каждой доски свои параметры и карточки.", + "boardNamePh": "Например: стиль Ghibli", + "startWith": "Начать с", + "startDefault": "Стандартные параметры", + "startEmpty": "Без параметров", + "startCopy": "Параметры доски «{{name}}»", + "board": "Доска", + "boards": "Доски", + "boardCounts": "{{runs}} карточек, {{images}} картинок", + "renameBoard": "Переименовать доску", + "deleteBoard": "Удалить доску", + "deleteBoardConfirm": "Удалить доску «{{name}}»: {{runs}} карточек и {{images}} картинок? Это нельзя отменить.", + "nameRequired": "Введите название", + "parameters": "Параметры", + "fromQueue": "Из очереди", + "addCard": "Добавить тренировку", + "searchPh": "Поиск по названию, заметкам, тегам…", + "viewMode": "Вид", + "view_cards": "Карточки", + "view_table": "Таблица", + "view_matrix": "Матрица", + "view_web": "Паутинка", + "viewWeb": "Паутинка тренировок", + "showGaps": "Пустые места", + "toggleFilters": "Показать / скрыть фильтры", + "filters": "Фильтры", + "groupBy": "Группировка", + "groupByTitle": "Группировать карточки по (по порядку)", + "noGrouping": "Без группировки — все карточки в одном поле.", + "addLevel": "+ Добавить уровень", + "sort": "Сортировка", + "sortTitle": "Сортировать по (первое правило главнее)", + "sortCreated": "Дата создания", + "asc": "↑ по возр.", + "desc": "↓ по убыв.", + "cardFields": "Поля карточки", + "cardFieldsTitle": "Какие параметры показывать на карточках", + "thumbSize": "Размер превью", + "views": "Виды", + "viewsTitle": "Сохранённые виды", + "noViews": "Пока нет сохранённых видов.", + "saveView": "Сохранить текущий вид…", + "saveViewAs": "Название вида", + "saveViewPh": "Например: Сравнение LR", + "viewsNote": "Вид хранит фильтры, группировку, сортировку и режим ({{mode}}). Текущий вид запоминается сам.", + "resetFilters": "Сбросить фильтры", + "fStatus": "Статус", + "fMore": "Показывать только", + "fWithSamples": "С семплами", + "fWithoutSamples": "Без семплов", + "fFavorites": "Избранное", + "fMinRating": "Оценка от", + "fContains": "Содержит…", + "fUntried": "С этим значением карточек ещё нет", + "clear": "сбросить", + "from": "от", + "to": "до", + "ifShort": "если {{name}}", + "conditionalHint": "Показывается только при некоторых значениях {{name}}", + "status_planned": "Запланировано", + "status_training": "Идёт обучение", + "status_done": "Завершено", + "status_stopped": "Остановлено", + "task_pending": "в очереди", + "task_scheduled": "по расписанию", + "task_running": "идёт", + "task_paused": "на паузе", + "task_done": "готово", + "task_failed": "ошибка", + "task_canceled": "отменено", + "unset": "Не задано", + "unsetShort": "—", + "yes": "Да", + "no": "Нет", + "rating": "Оценка", + "rateN": "Оценка {{n}}", + "rating_1": "Слабо", + "rating_2": "Хорошо", + "rating_3": "Отлично", + "notRated": "Без оценки", + "untried": "Не пробовали", + "favorite": "Избранное", + "untitled": "Без названия", + "open": "Открыть", + "openCard": "Открыть {{name}}", + "cardActions": "Действия с карточкой", + "cardDetails": "Карточка тренировки", + "addSamples": "Добавить семплы", + "duplicate": "Скопировать и изменить", + "duplicated": "Копия создана — поменяйте нужные параметры", + "oneAway": "Отличается одним параметром", + "selectCompare": "Выбрать для сравнения", + "unselect": "Убрать из выбора", + "createTraining": "Создать тренировку", + "createTrainingShort": "Обучить", + "downloadConfig": "Скачать конфиг", + "noConfigToDownload": "У карточки нет привязанной тренировки и нет параметров, связанных с конфигом.", + "fragmentHeader": "График: только параметры этой карточки (тренировка не привязана). Добавьте в полный конфиг.", + "fragmentSaved": "Тренировка не привязана — сохранена часть конфига из параметров карточки.", + "deleteCardConfirm": "Удалить карточку «{{name}}» и её картинки ({{count}})?", + "noSamples": "Нет семплов — перетащите картинки сюда", + "dropHere": "Отпустите, чтобы добавить", + "stepN": "шаг {{n}}", + "epochN": "эпоха {{n}}", + "differsIn": "Отличается: {{name}}", + "planned": "Добавлено как запланированное", + "plan": "Запланировать", + "emptyPlace": "Ещё не пробовали", + "inQueue": "Уже есть в очереди:", + "adoptTaskHint": "Добавить эту тренировку из очереди на доску", + "nCards_one": "{{count}} карточка", + "nCards_few": "{{count}} карточки", + "nCards_many": "{{count}} карточек", + "nCards_other": "{{count}} карточки", + "nEmpty_one": "{{count}} пустое", + "nEmpty_few": "{{count}} пустых", + "nEmpty_many": "{{count}} пустых", + "nEmpty_other": "{{count}} пустых", + "nRuns_one": "{{count}} запуск", + "nRuns_few": "{{count}} запуска с одинаковыми настройками", + "nRuns_many": "{{count}} запусков с одинаковыми настройками", + "nRuns_other": "{{count}} запуска с одинаковыми настройками", + "nSamples_one": "{{count}} семпл", + "nSamples_few": "{{count}} семпла", + "nSamples_many": "{{count}} семплов", + "nSamples_other": "{{count}} семпла", + "stackDiffers": "выделено то, что отличается", + "stackSame": "настройки одинаковые", + "compare": "Сравнить", + "compareAll": "Сравнить все", + "selectedN_one": "Выбрана {{count}}", + "selectedN_few": "Выбрано {{count}}", + "selectedN_many": "Выбрано {{count}}", + "selectedN_other": "Выбрано {{count}}", + "clearSelection": "Снять выбор", + "pasteTarget": "Ctrl+V вставит картинку в неё", + "pasteWhere": "Откройте карточку (или выберите одну), чтобы вставить в неё картинку.", + "uploadedN_one": "Добавлена {{count}} картинка", + "uploadedN_few": "Добавлено {{count}} картинки", + "uploadedN_many": "Добавлено {{count}} картинок", + "uploadedN_other": "Добавлено {{count}} картинки", + "coverage": "Проверено {{filled}} из {{total}} сочетаний", + "gapsHint": "Выберите значения в фильтрах слева (например, пять LR и два scheduler) — доска покажет все сочетания и отметит пустые.", + "tooManyGaps": "Слишком много сочетаний (больше {{n}}). Зафиксируйте несколько параметров на одном значении в фильтрах.", + "neighboursBanner": "Карточки, которые отличаются от «{{name}}» ровно одним параметром: {{count}}", + "backToBoard": "Назад к доске", + "emptyBoardTitle": "На доске пока нет тренировок", + "emptyBoardBody": "Добавьте карточку вручную, возьмите тренировку из очереди или откройте «Матрицу» и спланируйте, что попробовать.", + "planInMatrix": "Спланировать в матрице", + "nothingMatches": "Под фильтры ничего не подходит.", + "training": "Тренировка", + "taskGone": "Задача удалена из очереди — карточка и картинки сохранены.", + "importSamples": "Импорт семплов", + "openInQueue": "Открыть в очереди", + "openVersion": "Открыть версию", + "unlinkTask": "Отвязать", + "looksLike": "Похоже на задачу #{{id}}", + "link": "Привязать", + "linkTask": "Привязать задачу", + "pickTask": "Выберите тренировку из очереди…", + "samples": "Семплы", + "stepForNew": "шаг", + "stepForNewHint": "Шаг обучения для новых картинок. Пусто — взять из имени файла (step_400, …-000800).", + "dropSamples": "Перетащите картинки, выберите файлы или вставьте через Ctrl+V", + "dropSamplesHint": "PNG, JPEG, WebP. Картинки копируются в приложение — перенос исходников карточку не сломает.", + "noStep": "Без шага", + "bestStep": "Лучший шаг", + "pasteHint": "Подсказка: Ctrl+V вставит картинку из буфера обмена в эту карточку.", + "hiddenValues": "Скрытые параметры", + "archivedParams": "Параметры в архиве", + "archived": "в архиве", + "noNeighbours": "Пока нет карточек, которые отличаются от этой одним параметром.", + "showOnBoard": "Показать на доске", + "notesPh": "Что заметила: сильные стороны, проблемы, идеи…", + "resultsLink": "Ссылка / путь", + "resultsLinkPh": "https://… или D:\\loras\\run7", + "tags": "Теги", + "tagsPh": "пережарено, теряет стиль…", + "badNumber": "Это не число", + "sample": "Семпл", + "prev": "Предыдущий", + "next": "Следующий", + "step": "Шаг", + "stepHint": "Шаг обучения, на котором получен семпл (не Total steps).", + "prompt": "Промпт", + "seed": "Seed", + "comment": "Комментарий", + "setBestStep": "Отметить шаг как лучший", + "unsetBestStep": "Снять отметку лучшего шага", + "deleteSampleConfirm": "Удалить этот семпл из карточки?", + "source": "Источник", + "compareTitle": "Сравнение", + "compareSub": "Колесо — масштаб, перетаскивание — сдвиг, двойной клик — 3× / вписать. Выделены отличающиеся параметры.", + "allSteps": "Все шаги", + "bestOfEach": "Лучший у каждой", + "any": "Любой", + "syncZoom": "Синхронный зум", + "fit": "Вписать", + "commonParams_one": "Одинаково у всех: {{count}} параметр", + "commonParams_few": "Одинаково у всех: {{count}} параметра", + "commonParams_many": "Одинаково у всех: {{count}} параметров", + "commonParams_other": "Одинаково у всех: {{count}} параметра", + "removeFromCompare": "Убрать из сравнения", + "noSampleAt": "Нет семпла на шаге {{n}}", + "noSample": "Нет семпла", + "moreMatches": "Этому шагу подходят ещё картинки", + "openFull": "Открыть в полном размере", + "compareNoImages": "У этих карточек пока нет семплов.", + "axisX": "Столбцы", + "axisY": "Строки", + "swapAxes": "Поменять оси", + "matrixNone": "— нет —", + "matrixPick": "Выберите параметр", + "matrixHint": "Выберите параметр для столбцов (и, если нужно, для строк). Остальные параметры работают как фильтры.", + "webColor": "Цвет по", + "webStats": "{{nodes}} карточек, {{edges}} связей, {{alone}} без соседей", + "webReset": "Сбросить вид", + "webCapped": "Нарисованы только первые {{n}} карточек — сузьте фильтры.", + "webEmpty": "Нет карточек для отображения.", + "webHelp": "Линия соединяет карточки, которые отличаются ровно одним параметром (наведите, чтобы увидеть каким). Колесо — масштаб, перетаскивание — сдвиг.", + "paramsTitle": "Параметры", + "paramsSub": "Переименовывайте, меняйте порядок, скрывайте или архивируйте параметры и значения. Связи с карточками сохраняются.", + "remapsPending_one": "При сохранении изменится {{count}} значение в карточках", + "remapsPending_few": "При сохранении изменятся {{count}} значения в карточках", + "remapsPending_many": "При сохранении изменятся {{count}} значений в карточках", + "remapsPending_other": "При сохранении изменятся {{count}} значения в карточках", + "paramsSaved": "Параметры сохранены", + "newParamName": "Название нового параметра", + "paramType": "Тип", + "type_list": "Список", + "type_number": "Число", + "type_bool": "Да / Нет", + "type_text": "Свободный текст", + "addParam": "Добавить", + "archive": "Архив", + "usedIn_one": "в {{count}} карточке", + "usedIn_few": "в {{count}} карточках", + "usedIn_many": "в {{count}} карточках", + "usedIn_other": "в {{count}} карточках", + "moveUp": "Выше", + "moveDown": "Ниже", + "showParam": "Показать параметр", + "hideParam": "Временно скрыть", + "edit": "Изменить", + "description": "Пояснение", + "descriptionPh": "Необязательная подсказка при наведении", + "condition": "Показывать", + "alwaysShown": "Всегда", + "whenParam": "когда {{name}} равно…", + "conditionPick": "Выберите хотя бы одно значение, иначе параметр не будет показываться.", + "values": "Значения", + "valueLabel": "Значение", + "addValues": "Добавить", + "addValuesPh": "Новые значения через запятую", + "numberValuesHint": "Это варианты для выбора; в карточке можно указать любое число. 0.0001 и 1e-4 — одно и то же значение.", + "noValuesYet": "Вариантов пока нет", + "editValue": "Нажмите, чтобы изменить", + "range": "Диапазон", + "stepSize": "шаг", + "addNValues_one": "Добавить {{count}} значение", + "addNValues_few": "Добавить {{count}} значения", + "addNValues_many": "Добавить {{count}} значений", + "addNValues_other": "Добавить {{count}} значения", + "deleteParam": "Удалить параметр", + "deleteParamUsed_one": "Используется в {{count}} карточке. Архив сохранит историю; удаление сотрёт значение в карточке.", + "deleteParamUsed_few": "Используется в {{count}} карточках. Архив сохранит историю; удаление сотрёт значение в этих карточках.", + "deleteParamUsed_many": "Используется в {{count}} карточках. Архив сохранит историю; удаление сотрёт значение в этих карточках.", + "deleteParamUsed_other": "Используется в {{count}} карточках. Архив сохранит историю; удаление сотрёт значение в этих карточках.", + "deleteValueUsed_one": "Используется в {{count}} карточке. Архив скроет значение из выбора, но сохранит историю.", + "deleteValueUsed_few": "Используется в {{count}} карточках. Архив скроет значение из выбора, но сохранит историю.", + "deleteValueUsed_many": "Используется в {{count}} карточках. Архив скроет значение из выбора, но сохранит историю.", + "deleteValueUsed_other": "Используется в {{count}} карточках. Архив скроет значение из выбора, но сохранит историю.", + "archiveInstead": "В архив", + "deleteAnyway": "Всё равно удалить", + "showConfigLink": "Связь с конфигом (для опытных)", + "hideConfigLink": "Скрыть связь с конфигом", + "configLinkHelp": "Ключ конфига обучения, куда записывается и откуда читается это число.", + "configKey": "Ключ конфига", + "configList": "в ключе список", + "configApply": "Записывает в конфиг (JSON)", + "configMatch": "Узнаётся в конфиге по (JSON, необязательно)", + "badJson": "Некорректный JSON-объект", + "createTitle": "Создать тренировку", + "createSub": "Новая версия копируется из базовой (датасет, reg-набор, конфиг) и получает эти параметры.", + "noValuesSet": "Параметры не заданы — у копии останется конфиг базовой версии.", + "noProjects": "Проектов пока нет — сначала создайте проект.", + "goProjects": "К проектам", + "project": "Проект", + "baseVersion": "Скопировать из версии", + "baseVersionHint": "Отсюда берутся датасет, капшены, reg-набор и остальной конфиг.", + "baseNoConfig": "У этой версии ещё нет конфига обучения. Откройте её на шаге «Тренировка» или выберите другую версию.", + "newVersionLabel": "Название новой версии", + "labelRequired": "Введите название", + "labelInvalid": "Только латиница, цифры и _ . -", + "labelTaken": "В проекте уже есть версия с таким названием", + "configChanges": "Изменения в конфиге", + "noConfigChanges": "Менять нечего — параметры не связаны с конфигом.", + "notApplied": "Не записываются в конфиг (нет связи с конфигом): {{names}}.", + "queueNow": "Сразу поставить в очередь", + "creating": "Создаю…", + "createAndQueue": "Создать и поставить в очередь", + "createVersion": "Создать версию", + "createFailed": "Не получилось создать тренировку", + "queueFailed": "Версия создана и привязана, но в очередь не встала: {{msg}}", + "versionNote": "Создано из «Графика»", + "trainingQueued": "Версия {{label}} создана и поставлена в очередь", + "versionCreated": "Версия {{label}} создана", + "importTitle": "Взять тренировку из очереди", + "importSub": "Параметры читаются из сохранённого конфига задачи; семплы можно скопировать следующим шагом.", + "searchTasks": "Поиск по проекту, версии, #id…", + "noTasks": "Тренировок не найдено.", + "nothingRecognised": "В конфиге не узнан ни один параметр доски", + "noTaskConfig": "У задачи нет сохранённого конфига", + "alreadyOnBoard": "Уже есть на этой доске", + "pickSamplesTitle": "Семплы задачи #{{id}}", + "pickSamplesSub": "Выберите семплы по шагам. Они копируются в карточку — удаление задачи их не затронет.", + "taskNoSamples": "У этой задачи нет семплов.", + "alreadyAdded": "добавлено", + "importing": "Импорт…", + "importN_one": "Импортировать {{count}}", + "importN_few": "Импортировать {{count}}", + "importN_many": "Импортировать {{count}}", + "importN_other": "Импортировать {{count}}", + "importedN_one": "Импортирован {{count}} семпл", + "importedN_few": "Импортировано {{count}} семпла", + "importedN_many": "Импортировано {{count}} семплов", + "importedN_other": "Импортировано {{count}} семпла", + "configAdjusted": "Приложение изменило несовместимые настройки при сохранении: {{keys}}. В карточке указано то, что реально будет обучаться.", + "showGapsHint": "Покажет непроверенные сочетания выбранных ниже значений", + "andHigher": "и выше", + "noGroupingShort": "Без группировки", + "groupByParam": "Группы: {{name}}", + "sortNewest": "Сначала новые", + "sortOldest": "Сначала старые", + "sortBest": "Сначала лучшие", + "sortName": "По названию", + "sortByParam": "По {{name}} ↑", + "viewSettings": "Настройки вида", + "thenGroup": "Затем группы по", + "thenSort": "Затем сортировать", + "none": "—", + "openTraining": "Открыть тренировку", + "versionOnly": "Версия создана, в очередь пока не поставлена", + "colorNone": "Без цвета", + "colorBy": "Цвет: {{name}}", + "sampleSize": "Размер семплов", + "size_s": "Мелко", + "size_m": "Средне", + "size_l": "Крупно", + "addBlank": "Пустая карточка", + "addBlankSub": "Параметры заполните сами", + "fromQueueSub": "Параметры и семплы возьмутся из тренировки", + "result": "Результат", + "dropShort": "Перетащите или Ctrl+V", + "dropShortSub": "PNG, JPEG, WebP", + "auto": "авто", + "pickFiles": "Выбрать файлы" } } diff --git a/studio/web/src/index.css b/studio/web/src/index.css index dc81581..4a477eb 100644 --- a/studio/web/src/index.css +++ b/studio/web/src/index.css @@ -5,6 +5,7 @@ @import './styles/preprocess-crop.css'; @import './styles/responsive.css'; @import './styles/mobile.css'; +@import './styles/graph.css'; @tailwind base; @tailwind components; @tailwind utilities; diff --git a/studio/web/src/pages/Projects.tsx b/studio/web/src/pages/Projects.tsx index 53eb51f..ea01eda 100644 --- a/studio/web/src/pages/Projects.tsx +++ b/studio/web/src/pages/Projects.tsx @@ -302,7 +302,6 @@ export default function ProjectsPage() { return (
{t('status.running')} : task.status === 'paused' - ? {t('status.paused')} - : {t(task.status === 'scheduled' ? 'status.scheduled' : 'status.pending')} + ? {t('status.paused')} + : task.status === 'scheduled' + ? {t('status.scheduled')} + : {t('status.pending')} let progress: React.ReactNode = — if (task.status === 'running' && runningPct != null) { @@ -720,7 +721,7 @@ function ProjectCard({ project: p, extras, queueInfo, progress, finishedAt, onAr {trainingNow ? Icon.hex(16) : Icon.folder(16)} {p.title} - {p.slug}{p.active_version_label ? ` · ${p.active_version_label}` : ''} + {p.slug}{p.active_version_label ? `, ${p.active_version_label}` : ''} {task.name} {badge} - #{task.id} · {task.config_name} + #{task.id} {task.config_name} {time} e.stopPropagation()}> @@ -911,8 +911,7 @@ function DataTasksCard({ live, history, projectTitles, fmt, onOpen, onJump, onCa
-
{t('queue.dataTitle')}
-
{t('queue.dataSub')}
+
{t('queue.dataTitle')}{t('queue.dataSub')}
{rows.length === 0 ? ( diff --git a/studio/web/src/pages/QueueDetail.tsx b/studio/web/src/pages/QueueDetail.tsx index d6c0f0c..9a45769 100644 --- a/studio/web/src/pages/QueueDetail.tsx +++ b/studio/web/src/pages/QueueDetail.tsx @@ -24,6 +24,7 @@ import MonitorDashboard, { type EvalMetricKey, } from '../components/MonitorDashboard' import { fmtParamValue, jobJumpPath, paramLabel } from './queue/jobUtils' +import HelpTip from '../components/ds/HelpTip' type Tab = 'overview' | 'log' | 'monitor' | 'eval' | 'outputs' | 'snapshot' const ALL_TABS: readonly Tab[] = ['overview', 'log', 'monitor', 'eval', 'outputs', 'snapshot'] @@ -223,7 +224,7 @@ export default function QueueDetailPage() { bits.push(t('queueDetail.finishedAtShort', { time: fmt.clock(task.finished_at) })) if (task.started_at) bits.push(t('queueDetail.lasted', { time: fmt.dur(task.finished_at - task.started_at) })) } - return bits.map((b, i) => {i > 0 && ' · '}{b}) + return bits.map((b, i) => {i > 0 && ', '}{b}) })() const primary = (() => { @@ -275,7 +276,7 @@ export default function QueueDetailPage() {
@@ -675,7 +676,7 @@ function SamplesCard({ taskId, live, onRender, canRender }: { taskId: number; li {zoom != null && items && items[zoom] && ( 0} @@ -731,7 +732,7 @@ function PropsCard({ task, fmt, onSaved }: { task: Task; fmt: QueueFormat; onSav
- + {task.project_id != null && task.version_id != null && ( {t('queueDetail.sourceLink', { projectId: task.project_id, versionId: task.version_id })}} /> )} @@ -1051,22 +1052,19 @@ function EvalTab({ task }: { task: Task }) {
-
{t('queueDetail.howToRead')}
{t('queueDetail.howToReadSub')}
+
{t('queueDetail.howToRead')}<>{t('queueDetail.howToReadSub')} {t('queueDetail.evalWhereToEnable')}
{EVAL_METRIC_KEYS.map((k) => (
{METRIC_CODE[k]}{t(`queueDetail.metricHint.${k}`)}
))} -
- {t('queueDetail.evalWhereToEnable')} -
{last && (
{t('queueDetail.evalRunTitle')}
{t('queueDetail.evalRunSub')}
- + {last.sample_run?.summary?.total != null && } {last.checkpoint?.path && } @@ -1349,7 +1347,7 @@ export function OutputsTab({ taskId, onChanged }: { taskId: number; onChanged?:
{t('queueDetail.tabOutputs')}
{data?.output_dir || (data ? t('queueDetail.noProjectAssoc') : t('common.loading'))} - {hasFiles && ` · ${t('queueDetail.filesCount', { count: sortedFiles.length })} · ${fmt.bytes(totalSize)}`} + {hasFiles && `, ${t('queueDetail.filesCount', { count: sortedFiles.length })}, ${fmt.bytes(totalSize)}`}
{data?.output_dir && ( diff --git a/studio/web/src/pages/project/Overview.tsx b/studio/web/src/pages/project/Overview.tsx index adc360a..aa04c0b 100644 --- a/studio/web/src/pages/project/Overview.tsx +++ b/studio/web/src/pages/project/Overview.tsx @@ -295,9 +295,9 @@ export default function ProjectOverview() { const subtitle = ( <> {t('overview.downloads', { count: project.download_image_count ?? 0 })} - {' · '}{t('projects.versions', { count: project.versions.length })} - {' · '}{t('overview.createdOn', { date: fmtDate(project.created_at, i18n.language, true) })} - {activeVersion && <>{' · '}{t('overview.activeLabel')} {activeVersion.label}} + {', '}{t('projects.versions', { count: project.versions.length })} + {', '}{t('overview.createdOn', { date: fmtDate(project.created_at, i18n.language, true) })} + {activeVersion && <>{', '}{t('overview.activeLabel')} {activeVersion.label}} {project.note && {project.note}} ) @@ -548,7 +548,7 @@ function ckptLabel(t: (k: string, o?: Record) => string, name: let label = step ? t('overview.ckptStep', { n: Number(step[1]).toLocaleString() }) : epoch ? t('overview.ckptEpoch', { n: Number(epoch[1]) }) : t('overview.ckptFinal') - if (ema) label += ' · EMA' + if (ema) label += ', EMA' return label } @@ -800,7 +800,7 @@ function FilesTable({ version, rows, outputs, taskId, onRender }: { <>
- output/ · {t('projects.ckpts', { count: rows.length })}{sized ? ` · ${fmtBytes(t, total)}` : ''} + output/, {t('projects.ckpts', { count: rows.length })}{sized ? `, ${fmtBytes(t, total)}` : ''} {taskId != null && relPaths.length > 0 && ( @@ -851,7 +851,7 @@ function FilesTable({ version, rows, outputs, taskId, onRender }: { {Icon.hex(13)} {row.name} - {ckptLabel(t, row.name)}{isFinal ? ` · ${t('overview.ckptResult')}` : ''} + {ckptLabel(t, row.name)}{isFinal ? `, ${t('overview.ckptResult')}` : ''} @@ -938,7 +938,7 @@ function VersionsCard({ project, selectedVid, tasks, onOpen, onCreate, onActivat
{t('overview.versions')}
{t('projects.versions', { count: project.versions.length })} - {active && <>{' · '}{t('overview.activeLabel')} {active.label}} + {active && <>{', '}{t('overview.activeLabel')} {active.label}}
@@ -976,7 +976,7 @@ function VersionsCard({ project, selectedVid, tasks, onOpen, onCreate, onActivat > {v.label}{badgeOf(v)} - {meta.length > 0 && {meta.join(' · ')}} + {meta.length > 0 && {meta.join(', ')}} ({ ...it, label: String(it.label) }))} /> @@ -1037,10 +1037,10 @@ function CompositionCard({ version, sizes }: { } const sorted = Array.from(m.values()).sort((a, b) => b.n - a.n) const out = sorted.slice(0, 4).map((b) => ({ - label: b.w === b.h ? `${b.w}²` : `${b.w}×${b.h}`, title: `${b.w}×${b.h} · ${b.n}`, n: b.n, mute: false, + label: b.w === b.h ? `${b.w}²` : `${b.w}×${b.h}`, title: `${b.w}×${b.h}, ${b.n}`, n: b.n, mute: false, })) const other = sorted.slice(4).reduce((s, b) => s + b.n, 0) - if (other > 0) out.push({ label: t('overview.compOther'), title: `${t('overview.compOther')} · ${other}`, n: other, mute: true }) + if (other > 0) out.push({ label: t('overview.compOther'), title: `${t('overview.compOther')}, ${other}`, n: other, mute: true }) return out }, [sizes, t]) const maxBucket = Math.max(1, ...buckets.map((b) => b.n)) diff --git a/studio/web/src/pages/project/steps/Curation.tsx b/studio/web/src/pages/project/steps/Curation.tsx index 82daaeb..4eabf2e 100644 --- a/studio/web/src/pages/project/steps/Curation.tsx +++ b/studio/web/src/pages/project/steps/Curation.tsx @@ -17,6 +17,7 @@ import { useDialog } from '../../../components/Dialog' import { useToast } from '../../../components/Toast' import { parseFolderMeta } from '../../../lib/folderMeta' import { useEventStream } from '../../../lib/useEventStream' +import HelpTip from '../../../components/ds/HelpTip' // ---------- sorting ---------- type SortMode = @@ -742,7 +743,7 @@ export default function CurationPage() {
-
{isVal ? t('curate.valPanelTitle') : t('curate.trainPanelTitle')}
+
{isVal ? <>{t('curate.valPanelTitle')}{t('curate.valHint')} : t('curate.trainPanelTitle')}
{isVal ? t('curate.valSubtitle', { total: valView?.val_total ?? 0, sel: rightSel.size }) @@ -763,9 +764,7 @@ export default function CurationPage() {
- {isVal ? ( -
{t('curate.valHint')}
- ) : ( + {isVal ? null : (
{folderNames.length === 0 && {t('curate.noTrainFolders')}} {folderNames.map((f) => { diff --git a/studio/web/src/pages/project/steps/Download.tsx b/studio/web/src/pages/project/steps/Download.tsx index 46f5911..7515b33 100644 --- a/studio/web/src/pages/project/steps/Download.tsx +++ b/studio/web/src/pages/project/steps/Download.tsx @@ -250,9 +250,9 @@ export default function DownloadPage() { const nextTo = activeVersion ? `/projects/${project.id}/v/${activeVersion.id}/curate` : `/projects/${project.id}` const uploadDetail = uploader.busy ? (uploader.processing ? t('download.uploadProcessing') : t('download.uploading')) : null const logDetail = jobActive && job - ? `#${job.id} ${t('dataset.logScrape')}${jobProgress ? ` · ${jobProgress.n.toLocaleString()} / ${jobProgress.total.toLocaleString()}` : ''}` + ? `#${job.id} ${t('dataset.logScrape')}${jobProgress ? `, ${jobProgress.n.toLocaleString()} / ${jobProgress.total.toLocaleString()}` : ''}` : uploadDetail - ? `${t('dataset.logUpload')} · ${uploadDetail}` + ? `${t('dataset.logUpload')}, ${uploadDetail}` : null return ( @@ -641,7 +641,7 @@ function UploadCard({ uploader, lastUpload, onDismissResult }: { {t('download.added')} {lastUpload.added.length} - {lastUpload.skipped.length > 0 && <> · {t('download.skipped')} {lastUpload.skipped.length}} + {lastUpload.skipped.length > 0 && <> {t('download.skipped')} {lastUpload.skipped.length}} @@ -719,7 +719,7 @@ function StatsCard({ project, files, selected }: { {formats.map((f, i) => ( - {f.ext} · {f.n.toLocaleString()} · {Math.round((f.n / files.length) * 100)}% + {f.ext}, {f.n.toLocaleString()}, {Math.round((f.n / files.length) * 100)}% ))}
diff --git a/studio/web/src/pages/project/steps/Preprocess.tsx b/studio/web/src/pages/project/steps/Preprocess.tsx index 099cb31..7dc4659 100644 --- a/studio/web/src/pages/project/steps/Preprocess.tsx +++ b/studio/web/src/pages/project/steps/Preprocess.tsx @@ -493,7 +493,7 @@ export default function PreprocessPage() { visibleRows[previewIdx].filename, visibleRows[previewIdx].folder, 1600, ) + `&_=${visibleRows[previewIdx].mtime}`} - caption={`${visibleRows[previewIdx].name} · ${ + caption={`${visibleRows[previewIdx].name}, ${ visibleRows[previewIdx].status === 'processed' ? t('ppFrame.previewProcessed') : t('ppFrame.previewPending') }`} index={previewIdx} @@ -592,7 +592,7 @@ function OperationPanel({ {t('preprocess.needDownload')}{' '} {upscaler?.kind === 'custom' ? t('preprocess.customModelLocal') - : `${upscaler?.hf_repo ?? upscaler?.ms_repo ?? '—'} · ~${upscaler?.size_mb ?? 64} MB`} + : `${upscaler?.hf_repo ?? upscaler?.ms_repo ?? '—'}, ~${upscaler?.size_mb ?? 64} MB`}
{activeImage && ( - {activeImage.name} · {activeImage.w}×{activeImage.h} · {arLabel(activeImage.w, activeImage.h)} + {activeImage.name} {activeImage.w}×{activeImage.h}, {arLabel(activeImage.w, activeImage.h)} )} @@ -841,7 +842,7 @@ function RightRail({ return (
- {t('preprocessCrop.rrProgress')} + {t('preprocessCrop.rrProgress')}{t('preprocessCrop.rrNote')} {pct}%
@@ -853,7 +854,6 @@ function RightRail({ {lastClusterK !== null && ( )} -

{t('preprocessCrop.rrNote')}

{t('preprocessCrop.rrArDist')} diff --git a/studio/web/src/pages/project/steps/PreprocessDuplicates.tsx b/studio/web/src/pages/project/steps/PreprocessDuplicates.tsx index e5a49f7..d1fada9 100644 --- a/studio/web/src/pages/project/steps/PreprocessDuplicates.tsx +++ b/studio/web/src/pages/project/steps/PreprocessDuplicates.tsx @@ -17,6 +17,7 @@ import StepShell from '../../../components/StepShell' import PreprocessToolsBar, { PreprocessCard, PreprocessHeadTools } from '../../../components/preprocess/PreprocessToolsBar' import { useToast } from '../../../components/Toast' import { useEventStream } from '../../../lib/useEventStream' +import HelpTip from '../../../components/ds/HelpTip' interface Ctx { project: ProjectDetail @@ -276,7 +277,7 @@ function DuplicateOperationPanel({ options, busy, onOptionsChange, result, selec
-
{t('duplicates.statsTitle')}
+
{t('duplicates.statsTitle')}{t('ppFrame.dupNote')}
{t('duplicates.statsGroups')}{result?.group_count ?? '—'}
{t('duplicates.statsCandidates')}{result?.candidate_count ?? '—'}
{t('duplicates.statsCrops')}{result?.crop_relation_count ?? '—'}
@@ -286,8 +287,6 @@ function DuplicateOperationPanel({ options, busy, onOptionsChange, result, selec
{t('duplicates.statsElapsed')}{result ? `${result.elapsed_seconds}s` : '—'}
-
{t('ppFrame.dupNote')}
-
{activeImage && ( - {activeImage.name} · {activeImage.w}×{activeImage.h} + {activeImage.name} {activeImage.w}×{activeImage.h} )} - - diff --git a/studio/web/src/pages/project/steps/PreprocessOverview.tsx b/studio/web/src/pages/project/steps/PreprocessOverview.tsx index 920bfbc..4ce43a0 100644 --- a/studio/web/src/pages/project/steps/PreprocessOverview.tsx +++ b/studio/web/src/pages/project/steps/PreprocessOverview.tsx @@ -135,7 +135,7 @@ export default function PreprocessOverviewPage() { previewUrl: api.projectThumbUrl(project.id, im.source, 'download', 1600, im.mtime, true), compareSrc: trainThumb(1600), badge: t('preprocessOverview.badgeProcessed'), - caption: `${im.name} · ${im.w}×${im.h}`, + caption: `${im.name}, ${im.w}×${im.h}`, } } // Unprocessed, unchanged: the image in train is just a copy of the download original @@ -143,7 +143,7 @@ export default function PreprocessOverviewPage() { name: im.name, thumbUrl: trainThumb(256), previewUrl: trainThumb(1600), - caption: `${im.name} · ${im.w}×${im.h}`, + caption: `${im.name}, ${im.w}×${im.h}`, } }), [workspace, project.id, vid, t], @@ -154,7 +154,7 @@ export default function PreprocessOverviewPage() { // duplicate_removed is physically deleted; thumbnails go through the download bucket + im.source (origin) thumbUrl: api.projectThumbUrl(project.id, im.source, 'download', 256, im.mtime, true), previewUrl: api.projectThumbUrl(project.id, im.source, 'download', 1600, im.mtime, true), - caption: im.w && im.h ? `${im.source} · ${im.w}×${im.h}` : im.source, + caption: im.w && im.h ? `${im.source}, ${im.w}×${im.h}` : im.source, })), [removed, project.id], ) diff --git a/studio/web/src/pages/project/steps/Regularization.tsx b/studio/web/src/pages/project/steps/Regularization.tsx index 190c195..97b362e 100644 --- a/studio/web/src/pages/project/steps/Regularization.tsx +++ b/studio/web/src/pages/project/steps/Regularization.tsx @@ -683,7 +683,7 @@ function StatusCard({ m ? (m.generation_method === 'ai_base' ? t('reg.sourceAi') : t('reg.sourceBooru')) : '—', m ? [m.generation_method === 'ai_base' ? t('reg.sourceAiSub') : m.api_source, - m.auto_tagged ? (m.auto_tag_kind ?? t('reg.taggerUnknown')) : t('reg.statusTaggerOff')].join(' · ') + m.auto_tagged ? (m.auto_tag_kind ?? t('reg.taggerUnknown')) : t('reg.statusTaggerOff')].join(', ') : '—')} {cell(false, t('reg.statusCellInvalidTags'), 0 ? 'var(--amber-text)' : undefined }}>{m ? failed.length : '—'}, @@ -1410,7 +1410,7 @@ function RegImages({
{t('reg.imagesFooter', { n: allItems.length, folders: folders.length })} - {selected.size > 0 && ` · ${t('reg.regPreviewSelected', { n: selected.size })}`} + {selected.size > 0 && `, ${t('reg.regPreviewSelected', { n: selected.size })}`} {activeFolder !== null && activeFolder !== '' && ( diff --git a/studio/web/src/pages/project/steps/TagEdit.tsx b/studio/web/src/pages/project/steps/TagEdit.tsx index 427ba0a..b7f8609 100644 --- a/studio/web/src/pages/project/steps/TagEdit.tsx +++ b/studio/web/src/pages/project/steps/TagEdit.tsx @@ -403,7 +403,7 @@ export default function TagEditPage() {
{t('tagEdit.imagesTitle')}
{t('tagEdit.imagesSub', { n: filteredKeys.length })} - {pickedTag && ` · ${t('tagEdit.withTag', { n: pickedCount, tag: pickedTag })}`} + {pickedTag && `, ${t('tagEdit.withTag', { n: pickedCount, tag: pickedTag })}`}
@@ -417,7 +417,7 @@ export default function TagEditPage() { > {folderNames.map((f) => ( - + ))}
@@ -461,7 +461,7 @@ export default function TagEditPage() {
{activeKey - ? [dims && `${dims.w} × ${dims.h}`, activeFolder, activeIndex >= 0 && `${activeIndex + 1} / ${navKeys.length}`].filter(Boolean).join(' · ') + ? [dims && `${dims.w} × ${dims.h}`, activeFolder, activeIndex >= 0 && `${activeIndex + 1} / ${navKeys.length}`].filter(Boolean).join(', ') : '—'}
diff --git a/studio/web/src/pages/project/steps/Train.tsx b/studio/web/src/pages/project/steps/Train.tsx index 72bd79d..f489b70 100644 --- a/studio/web/src/pages/project/steps/Train.tsx +++ b/studio/web/src/pages/project/steps/Train.tsx @@ -39,6 +39,7 @@ import { } from '../../../lib/preset-helpers' import FamilySwitchDialog from '../../../components/FamilySwitchDialog' import TriggerWordCard from '../../../components/TriggerWordCard' +import HelpTip from '../../../components/ds/HelpTip' // Global model fields come from global settings and are read-only per version const GLOBAL_MODEL_FIELDS = [ @@ -48,6 +49,11 @@ const GLOBAL_MODEL_FIELDS = [ 't5_tokenizer_path', ] +// Filled in by the project (train/, reg/, output/ of the version); hidden on this page. +const DATASET_PATH_FIELDS = ['data_dir', 'reg_data_dir', 'output_dir', 'output_name'] +// Continue-training fields live on their own tab (Finishing). +const RESUME_FIELDS = ['resume_lora', 'resume_state'] + interface Ctx { project: ProjectDetail activeVersion: Version | null @@ -229,12 +235,15 @@ export default function TrainPage() { // values; OFF (users with independent models): the fields are editable + get a // reset button, and on fork the preset's absolute paths are respected. const disabledFields = autoSyncPaths ? GLOBAL_MODEL_FIELDS : [] + // Temporarily hidden: while the paths are synced from Settings they are + // read-only noise on this page (values still round-trip through the config). + const hiddenFields = [...(autoSyncPaths ? GLOBAL_MODEL_FIELDS : []), ...DATASET_PATH_FIELDS] const disabledHints = useMemo(() => { const h: Record = {} if (autoSyncPaths) { const node = ( <> - {t('train.globalAutoLockedPrefix')} ·{' '} + {t('train.globalAutoLockedPrefix')},{' '} ))} - -
- {/* Trigger word: under DOP (gated row) when DOP is on and its field is - shown; otherwise at the top of the data tab, next to captions. */} - {tab === 'data' && !triggerUnderDop && triggerRow} RESUME_FIELDS.includes(n) : tab === 'system' ? (n) => !RESUME_FIELDS.includes(n) : undefined} /> {familySwitchTarget && ( interface ChangeEntry { @@ -1306,7 +1288,7 @@ function TrainKpis({ stats }: { stats: TrainStats }) {
{TrainIcon.gpu} - {sys ? [t('train.kpiCpu', { n: Math.round(sys.cpu_pct) }), gpu?.temp_c != null ? `${gpu.temp_c} °C` : null].filter(Boolean).join(' · ') : ''} + {sys ? [t('train.kpiCpu', { n: Math.round(sys.cpu_pct) }), gpu?.temp_c != null ? `${gpu.temp_c} °C` : null].filter(Boolean).join(', ') : ''}
@@ -1352,9 +1334,9 @@ function StepBudget({ stats, activeVersion }: { stats: TrainStats; activeVersion n: stats.shownEffective || null, drop: stats.navitOn ? t('train.budgetNavitSamples') - : [repeats.length ? repeats.map((r) => `×${r}`).join(' · ') + ' ' + t('train.budgetByFolders') : null, + : [repeats.length ? repeats.map((r) => `×${r}`).join(', ') + ' ' + t('train.budgetByFolders') : null, stats.resoCount > 1 ? t('train.budgetResos', { n: stats.resoCount }) : null, - regImages > 0 ? t('train.budgetPlusReg') : null].filter(Boolean).join(' · '), + regImages > 0 ? t('train.budgetPlusReg') : null].filter(Boolean).join(', '), tone: 'soft', dropTone: 'mute', }) @@ -1364,7 +1346,7 @@ function StepBudget({ stats, activeVersion }: { stats: TrainStats; activeVersion drop: stats.navitOn ? (stats.navitEst ? t('train.budgetNavitSteps', { packs: formatNum(stats.navitEst.packs_per_epoch, lang), ga: stats.ga }) : t('train.navitEstimating')) : t('train.budgetStepsNote', { bs: stats.bs, ga: stats.ga, epochs: stats.epochs, total: stats.finalTotal != null ? formatNum(stats.finalTotal, lang) : '—' }) - + (stats.maxStepsTruncates ? ` · ${t('train.maxStepsLabel', { n: stats.maxSteps })}` : ''), + + (stats.maxStepsTruncates ? `, ${t('train.maxStepsLabel', { n: stats.maxSteps })}` : ''), tone: 'green', dropTone: 'mute', }) @@ -1372,7 +1354,7 @@ function StepBudget({ stats, activeVersion }: { stats: TrainStats; activeVersion return (
-
{t('train.budgetTitle')}
{t('train.budgetSub')}
+
{t('train.budgetTitle')}{t('train.budgetSub')}
{rows.map((r) => ( @@ -1503,7 +1485,7 @@ function BucketBars({ dist }: { dist: BucketDistribution | null }) {
{native ? t('train.navitDistTitle') : t('train.bucketDistTitle')}
- {top.map((b) => )} + {top.map((b) => )} {restCount > 0 && }
@@ -1654,7 +1636,7 @@ function RecentChanges({ changes, schema, onRevert }: { return (
-
{t('train.recentTitle')}
{t('train.recentSub')}
+
{t('train.recentTitle')}{t('train.recentSub')}
{changes.length === 0 ? (
{t('train.recentEmpty')}
@@ -1730,8 +1712,8 @@ function TrainLogBar({ project, vid }: { project: ProjectDetail; vid: number }) `#${task.id} train`, monitor?.epoch != null && monitor.total_epochs ? t('train.logbarEpoch', { e: monitor.epoch, total: monitor.total_epochs }) : null, lastLoss != null ? `loss ${lastLoss.toFixed(4)}` : null, - ].filter(Boolean).join(' · ') - : `#${task.id} train · ${t(`train.runStatus_${task.status}`, { defaultValue: task.status })}` + ].filter(Boolean).join(', ') + : `#${task.id} train, ${t(`train.runStatus_${task.status}`, { defaultValue: task.status })}` const pct = running && monitor?.step != null && monitor.total_steps ? (monitor.step / monitor.total_steps) * 100 : null return } diff --git a/studio/web/src/pages/queue/DataJobsPanel.tsx b/studio/web/src/pages/queue/DataJobsPanel.tsx index db134e3..fb2bb3f 100644 --- a/studio/web/src/pages/queue/DataJobsPanel.tsx +++ b/studio/web/src/pages/queue/DataJobsPanel.tsx @@ -150,7 +150,7 @@ export default function DataJobsPanel({ {KIND_LABEL[kindOf] ?? kindOf}
- {projectLabel}{task.version_id ? ` · v${task.version_id}` : ''} + {projectLabel}{task.version_id ? `, v${task.version_id}` : ''}
diff --git a/studio/web/src/pages/tools/Generate.tsx b/studio/web/src/pages/tools/Generate.tsx index f21d78e..a41d207 100644 --- a/studio/web/src/pages/tools/Generate.tsx +++ b/studio/web/src/pages/tools/Generate.tsx @@ -874,12 +874,12 @@ export default function GeneratePage() { const firstLora = shown.loras[0] const resultSub = [ firstLora - ? `${firstLora.name.replace(/\.safetensors$/i, '')} · ${t('generate.weightShort', { w: firstLora.scale.toFixed(2) })}` + ? `${firstLora.name.replace(/\.safetensors$/i, '')}, ${t('generate.weightShort', { w: firstLora.scale.toFixed(2) })}` : t('generate.noLora'), shown.loras.length > 1 ? `+${shown.loras.length - 1}` : null, `seed ${shown.seed}`, - ].filter(Boolean).join(' · ') - const paramsLine = `${t('generate.stepsN', { count: shown.steps })} · guidance ${shown.cfg.toFixed(1)} · ${schemaEnumLabel('sample_sampler_name', shown.sampler, t)} / ${schemaEnumLabel('sample_scheduler', shown.scheduler, t)}` + ].filter(Boolean).join(', ') + const paramsLine = `${t('generate.stepsN', { count: shown.steps })}, guidance ${shown.cfg.toFixed(1)}, ${schemaEnumLabel('sample_sampler_name', shown.sampler, t)} / ${schemaEnumLabel('sample_scheduler', shown.scheduler, t)}` const footerStatus = historyOverride || !currentTask || busy ? null : currentTask.status === 'done' @@ -911,7 +911,6 @@ export default function GeneratePage() { return (
= {}): Run { + return { + id, board_id: 1, name: `run ${id}`, values, status: 'done', notes: '', link: '', favorite: false, + rating: null, best_step: null, tags: [], task_id: null, project_id: null, version_id: null, + created_at: id, updated_at: id, images: [], ...extra, + } +} + +const RUNS = [ + run(1, { lr: 0.0001, scheduler: 'cosine' }, { rating: 3 }), + run(2, { lr: 0.00002, scheduler: 'cosine' }), +] + +const fetchMock = vi.fn() +function json(body: unknown) { + return Promise.resolve({ + ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body), + headers: new Headers({ 'content-type': 'application/json' }), + } as Response) +} + +beforeEach(() => { + localStorage.clear() + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockReset() + fetchMock.mockImplementation((url: string) => { + if (url.endsWith('/api/graph/boards')) { + return json({ items: [{ id: 1, name: 'Style', note: '', run_count: 2, image_count: 0, created_at: 1, updated_at: 1 }] }) + } + if (url.endsWith('/api/graph/boards/1')) { + return json({ board: { id: 1, name: 'Style', note: '', params: PARAMS, views: [], created_at: 1, updated_at: 1 }, runs: RUNS }) + } + if (url.includes('/api/graph/tasks')) { + return json({ items: [{ + id: 77, name: 't', status: 'pending', created_at: 1, started_at: null, finished_at: null, + project_id: 1, version_id: 2, project_title: 'Proj', version_label: 'v2', note: '', + has_config: true, config: { learning_rate: 1e-4, lr_scheduler: 'none' }, + }] }) + } + return json({}) + }) +}) + +function renderPage() { + return render( + + + + + + + , + ) +} + +describe('GraphPage', () => { + it('shows the board and its cards', async () => { + renderPage() + expect(await screen.findByText('run 1')).toBeInTheDocument() + expect(screen.getByText('run 2')).toBeInTheDocument() + expect(screen.getByRole('combobox', { name: 'Board' })).toHaveValue('1') + }) + + it('lays out empty places for the filtered values and offers matching queue tasks', async () => { + const user = userEvent.setup() + renderPage() + await screen.findByText('run 1') + // LR: both values, Scheduler: both values -> 4 places, 2 filled + await user.click(screen.getByRole('button', { name: /^LR/, expanded: false })) + const lrBlock = screen.getByText('LR', { selector: '.gr-fsec-title' }).closest('section')! + for (const box of within(lrBlock).getAllByRole('checkbox').slice(0, 2)) await user.click(box) + await user.click(screen.getByRole('button', { name: /^Scheduler/, expanded: false })) + const schBlock = screen.getByText('Scheduler', { selector: '.gr-fsec-title' }).closest('section')! + await user.click(within(schBlock).getByRole('checkbox', { name: /Constant/ })) + await user.click(within(schBlock).getByRole('checkbox', { name: /Cosine/ })) + await user.click(screen.getByRole('button', { name: /Empty places/ })) + await waitFor(() => expect(screen.getByText(/2 of 4 combinations tried/)).toBeInTheDocument()) + expect(screen.getAllByText('Not tried yet')).toHaveLength(2) + // the pending task has LR 1e-4 + Constant: it sits in that empty place + expect(await screen.findByText(/#77/)).toBeInTheDocument() + }) + + it('renders the table, matrix and web views', async () => { + const user = userEvent.setup() + renderPage() + await screen.findByText('run 1') + await user.click(screen.getByRole('tab', { name: /Table/ })) + expect(screen.getByRole('table')).toBeInTheDocument() + await user.click(screen.getByRole('tab', { name: /Matrix/ })) + await user.click(screen.getByRole('tab', { name: /Web/ })) + expect(screen.getByText(/2 cards · 1 links/)).toBeInTheDocument() + }) +}) diff --git a/studio/web/src/pages/tools/Graph.tsx b/studio/web/src/pages/tools/Graph.tsx new file mode 100644 index 0000000..3ff5418 --- /dev/null +++ b/studio/web/src/pages/tools/Graph.tsx @@ -0,0 +1,674 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDialog } from '../../components/Dialog' +import KebabMenu from '../../components/ds/KebabMenu' +import PageHead from '../../components/ds/PageHead' +import Popover, { MenuItems } from '../../components/ds/Popover' +import { useToast } from '../../components/Toast' +import { graphApi } from './graph/api' +import { GroupedCombos as CardsView, groupFilesByStep, type CardActions } from './graph/CardsView' +import CompareView from './graph/CompareView' +import CreateTrainingDialog from './graph/CreateTrainingDialog' +import FilterPanel from './graph/FilterPanel' +import ImageDialog from './graph/ImageDialog' +import { ImportTaskDialog, SamplePickerDialog } from './graph/ImportDialogs' +import MatrixView from './graph/MatrixView' +import { + activeValues, buildGaps, combosOfIdentical, comparisonParams, configPatch, filterRuns, GAP_LIMIT, groupItems, + newId, normalizeView, oneStepAway, paramById, sortRuns, statusFromTask, taskMatches, + taskValuesMap, visibleParams, type Combo, +} from './graph/model' +import ParamsManager, { type Remap } from './graph/ParamsManager' +import RunDrawer from './graph/RunDrawer' +import TableView from './graph/TableView' +import type { Board, GImage, GraphTask, Param, Run, SortKey, Values, ViewMode, ViewState } from './graph/types' +import { Ico, Modal, useValueLabel } from './graph/ui' +import { useGraph } from './graph/useGraph' +import WebView from './graph/WebView' + +const MODES: ViewMode[] = ['cards', 'table', 'matrix', 'web'] + +export default function GraphPage() { + const { t } = useTranslation() + const { toast } = useToast() + const dialog = useDialog() + const label = useValueLabel() + const g = useGraph() + const { board, runs, view, setView, tasks } = g + // Stable callbacks (the hook's result object itself is new on every render). + const { createRun, updateRun, duplicateRun, deleteRun, uploadImages } = g + const params = useMemo(() => board?.params ?? [], [board]) + const byId = useMemo(() => paramById(params), [params]) + + const [openRunId, setOpenRunId] = useState(null) + const [selected, setSelected] = useState>(new Set()) + const [neighboursOf, setNeighboursOf] = useState(null) + const [compare, setCompare] = useState(null) + const [image, setImage] = useState<{ runId: number; imageId: number } | null>(null) + const [createFor, setCreateFor] = useState<{ values: Values; runId?: number } | null>(null) + const [paramsOpen, setParamsOpen] = useState(false) + const [importOpen, setImportOpen] = useState(false) + const [samplePick, setSamplePick] = useState<{ runId: number; taskId: number } | null>(null) + const [newBoardOpen, setNewBoardOpen] = useState(false) + const [filtersOpen, setFiltersOpen] = useState(() => window.innerWidth > 900) + const fileRef = useRef(null) + const fileTarget = useRef(null) + + const openRun = runs.find((r) => r.id === openRunId) ?? null + // Close panels that point at cards that no longer exist. + useEffect(() => { if (openRunId != null && !openRun) setOpenRunId(null) }, [openRunId, openRun]) + useEffect(() => { setSelected(new Set()); setNeighboursOf(null); setOpenRunId(null) }, [board?.id]) + + // ── queue tasks → values, and which ones are not on the board yet ───────── + const taskValues = useMemo(() => taskValuesMap(tasks, params), [tasks, params]) + const taskById = useMemo(() => new Map(tasks.map((x) => [x.id, x])), [tasks]) + const freeTasks = useMemo(() => { + const used = new Set(runs.map((r) => r.task_id).filter((x): x is number => x != null)) + return tasks.filter((x) => !used.has(x.id) && x.has_config) + }, [tasks, runs]) + const tasksFor = useCallback((values: Values): GraphTask[] => + freeTasks.filter((x) => taskMatches(values, taskValues.get(x.id) ?? {}, params)), [freeTasks, taskValues, params]) + + // ── what's on screen ────────────────────────────────────────────────────── + const neighbourInfo = useMemo(() => { + const base = runs.find((r) => r.id === neighboursOf) + if (!base) return null + const list = oneStepAway(base, runs, params) + return { base, list, highlight: new Map(list.map((x) => [x.run.id, x.param])) } + }, [neighboursOf, runs, params]) + + const filtered = useMemo(() => { + if (!view) return [] + if (neighbourInfo) return [neighbourInfo.base, ...neighbourInfo.list.map((x) => x.run)] + return filterRuns(runs, params, view) + }, [runs, params, view, neighbourInfo]) + + const sorted = useMemo(() => (view ? sortRuns(filtered, view.sort, params) : []), [filtered, view, params]) + // Empty places need at least one parameter narrowed in the filters; without + // one there is nothing to span and the board shows cards as usual. + const gapAxes = view ? comparisonParams(params, view).length : 0 + const gaps = useMemo(() => (view?.gaps && !neighbourInfo && gapAxes > 0 ? buildGaps(sorted, params, view) : undefined), + [view, sorted, params, neighbourInfo, gapAxes]) + const combos: Combo[] = useMemo(() => { + if (gaps) return gaps + if (neighbourInfo) return sorted.map((r) => ({ key: `r${r.id}`, values: activeValues(r.values, params), runs: [r] })) + return combosOfIdentical(sorted, params) + }, [gaps, sorted, params, neighbourInfo]) + const groups = useMemo(() => (view && !neighbourInfo ? groupItems(combos, view.groupBy, params, (c) => c.values) : null), + [combos, view, params, neighbourInfo]) + + /** Single-value filters = what every place on screen shares. */ + const fixed = useMemo(() => { + const out: Values = {} + if (!view) return out + for (const p of params) { + const vals = view.filters[p.id]?.values + if (!p.archived && vals?.length === 1) out[p.id] = vals[0] + } + return out + }, [view, params]) + + // ── card actions ────────────────────────────────────────────────────────── + const autoName = useCallback((values: Values) => { + const act = activeValues(values, params) + const ids = (view?.shownParams ?? []).filter((id) => act[id] !== undefined) + return ids.slice(0, 4).map((id) => label(byId.get(id), act[id])).join(', ') + }, [params, view, label, byId]) + + // Read through a ref so card actions don't change identity on every filter tweak. + const autoNameRef = useRef(autoName) + autoNameRef.current = autoName + + const uploadTo = useCallback(async (run: Run, files: File[], step?: number | null) => { + const groupsByStep = step != null ? [{ step, files }] : groupFilesByStep(files) + let n = 0 + for (const gr of groupsByStep) { + const added = await uploadImages(run.id, gr.files, gr.step != null ? { step: gr.step } : undefined) + n += added.length + } + if (n) toast(t('graph.uploadedN', { count: n }), 'success') + }, [uploadImages, toast, t]) + + const downloadConfig = useCallback(async (run: Run) => { + const save = (blob: Blob, name: string) => { + const a = document.createElement('a') + a.href = URL.createObjectURL(blob) + a.download = name + document.body.appendChild(a) + a.click() + a.remove() + setTimeout(() => URL.revokeObjectURL(a.href), 2000) + } + const base = (run.name || `run_${run.id}`).replace(/[^\w.-]+/g, '_') + try { + const resp = await fetch(graphApi.runConfigUrl(run.id)) + if (resp.ok) { save(await resp.blob(), `${base}.yaml`); return } + } catch { /* fall through to the fragment */ } + const { patch } = configPatch(run.values, params) + if (!Object.keys(patch).length) { toast(t('graph.noConfigToDownload'), 'error'); return } + const lines = [`# ${t('graph.fragmentHeader')}`, ...Object.entries(patch).map(([k, v]) => `${k}: ${JSON.stringify(v)}`)] + save(new Blob([lines.join('\n') + '\n'], { type: 'application/x-yaml' }), `${base}.partial.yaml`) + toast(t('graph.fragmentSaved'), 'info') + }, [params, toast, t]) + + const adoptTask = useCallback(async (task: GraphTask, values: Values = {}) => { + const vals = { ...values, ...(taskValues.get(task.id) ?? {}) } + const r = await createRun({ + name: `${[task.project_title, task.version_label].filter(Boolean).join(' / ') || task.name} #${task.id}`, + values: vals, task_id: task.id, project_id: task.project_id, version_id: task.version_id, + status: statusFromTask(task.status), + }) + if (r) { + setOpenRunId(r.id) + setSamplePick({ runId: r.id, taskId: task.id }) + } + }, [createRun, taskValues]) + + const actions: CardActions = useMemo(() => ({ + open: (r) => setOpenRunId(r.id), + openImage: (r, img) => setImage({ runId: r.id, imageId: img.id }), + toggleSelect: (r) => setSelected((s) => { const n = new Set(s); if (n.has(r.id)) n.delete(r.id); else n.add(r.id); return n }), + rate: (r, n) => void updateRun(r.id, { rating: n }), + favorite: (r) => void updateRun(r.id, { favorite: !r.favorite }), + drop: (r, files) => void uploadTo(r, files), + pickFiles: (r) => { fileTarget.current = r.id; fileRef.current?.click() }, + duplicate: async (r) => { + const c = await duplicateRun(r.id) + if (c) { setOpenRunId(c.id); toast(t('graph.duplicated'), 'success') } + }, + neighbours: (r) => { setNeighboursOf(r.id); setOpenRunId(null) }, + createTraining: (values, r) => setCreateFor({ values, runId: r?.id }), + plan: async (values) => { + const r = await createRun({ values, status: 'planned', name: autoNameRef.current(values) }) + if (r) toast(t('graph.planned'), 'success') + }, + downloadConfig: (r) => void downloadConfig(r), + remove: async (r) => { + const ok = await dialog.confirm(t('graph.deleteCardConfirm', { name: r.name || `#${r.id}`, count: r.images.length }), { tone: 'danger', okText: t('common.delete') }) + if (ok && await deleteRun(r.id)) setSelected((s) => { const n = new Set(s); n.delete(r.id); return n }) + }, + adoptTask: (task, values) => void adoptTask(task, values), + compare: (list) => setCompare(list.map((r) => r.id)), + }), [updateRun, duplicateRun, createRun, deleteRun, uploadTo, toast, t, downloadConfig, dialog, adoptTask]) + + // Paste an image from the clipboard into the open card (or the one selected card). + useEffect(() => { + const onPaste = (e: ClipboardEvent) => { + const tag = (e.target as HTMLElement | null)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + const files = Array.from(e.clipboardData?.files ?? []).filter((f) => f.type.startsWith('image/')) + if (!files.length) return + const targetId = openRunId ?? (selected.size === 1 ? [...selected][0] : null) + const run = runs.find((r) => r.id === targetId) + e.preventDefault() + if (!run) { toast(t('graph.pasteWhere'), 'info'); return } + const named = files.map((f, i) => new File([f], f.name && f.name !== 'image.png' ? f.name : `pasted-${Date.now()}-${i}.png`, { type: f.type })) + void uploadTo(run, named) + } + window.addEventListener('paste', onPaste) + return () => window.removeEventListener('paste', onPaste) + }, [openRunId, selected, runs, uploadTo, toast, t]) + + // ── params save (remaps first, so card values follow the edit) ──────────── + const saveParams = async (next: typeof params, remaps: Remap[]) => { + if (!board) return false + try { + const byParam = new Map() + for (const r of remaps) byParam.set(r.paramId, [...(byParam.get(r.paramId) ?? []), { from: r.from, to: r.to }]) + for (const [pid, mapping] of byParam) await graphApi.remap(board.id, pid, mapping) + } catch (e) { + toast((e as Error).message, 'error') + return false + } + const ok = await g.saveParams(next) + if (ok) { + if (remaps.length) await g.loadBoard(board.id) + toast(t('graph.paramsSaved'), 'success') + } + return ok + } + + // ── boards ──────────────────────────────────────────────────────────────── + const createBoard = async (name: string, mode: 'default' | 'empty' | number) => { + try { + const r = await graphApi.createBoard({ + name, empty: mode === 'empty', copy_params_from: typeof mode === 'number' ? mode : undefined, + }) + await g.refreshBoards() + g.setBoardId(r.board.id) + setNewBoardOpen(false) + } catch (e) { + toast((e as Error).message, 'error') + } + } + + if (g.boards === null || (g.loading && !board)) { + return
+ } + + if (!board || !view) { + return ( +
+ +
+
+
{Ico.matrix}
+
{t('graph.welcomeTitle')}
+

{t('graph.welcomeBody')}

+
    +
  1. {t('graph.welcome1')}
  2. +
  3. {t('graph.welcome2')}
  4. +
  5. {t('graph.welcome3')}
  6. +
+ +
+
+ {newBoardOpen && setNewBoardOpen(false)} onCreate={createBoard} />} +
+ ) + } + + const setV = (fn: (v: ViewState) => ViewState) => setView(fn) + const total = runs.length + const withSamples = runs.filter((r) => r.images.length).length + const plannedN = runs.filter((r) => r.status === 'planned').length + const selectedRuns = runs.filter((r) => selected.has(r.id)) + const gapTotal = gaps?.length ?? 0 + const gapFilled = gaps?.filter((c) => c.runs.length).length ?? 0 + const needGapHint = view.gaps && !neighbourInfo && gapAxes === 0 + const compareRuns = compare ? compare.map((id) => runs.find((r) => r.id === id)).filter((r): r is Run => !!r) : [] + const imageRun = image ? runs.find((r) => r.id === image.runId) : undefined + + return ( +
+ { + const r = runs.find((x) => x.id === fileTarget.current) + const files = Array.from(e.target.files ?? []) + e.target.value = '' + if (r && files.length) void uploadTo(r, files) + }} /> + + + setParamsOpen(true) }, + { label: t('graph.newBoard'), onSelect: () => setNewBoardOpen(true), divider: true }, + { label: t('graph.renameBoard'), onSelect: async () => { + const name = await dialog.prompt(t('graph.renameBoard'), { defaultValue: board.name, validate: (v) => (v.trim() ? null : t('graph.nameRequired')) }) + if (name) await g.saveBoardMeta({ name: name.trim() }) + } }, + { label: t('graph.deleteBoard'), tone: 'err', divider: true, onSelect: async () => { + const ok = await dialog.confirm(t('graph.deleteBoardConfirm', { name: board.name, runs: runs.length, images: runs.reduce((n, r) => n + r.images.length, 0) }), { tone: 'danger', okText: t('common.delete') }) + if (!ok) return + await graphApi.deleteBoard(board.id) + const list = await g.refreshBoards() + g.setBoardId(list[0]?.id ?? null) + } }, + ]} /> + { + const r = await g.createRun({ values: fixed, status: 'planned', name: '' }) + if (r) setOpenRunId(r.id) + }} + onFromQueue={() => { void g.refreshTasks(); setImportOpen(true) }} /> + } + /> + +
+
+ + + {Ico.search} + setV((v) => ({ ...v, search: e.target.value }))} + placeholder={t('graph.searchPh')} aria-label={t('graph.searchPh')} /> + + {(view.mode === 'cards' || view.mode === 'table') && ( + + )} +
+ {MODES.map((m) => ( + + ))} +
+ setView(normalizeView(st, params))} + onSave={async (name) => g.saveBoardMeta({ views: [...board.views, { id: newId('v'), name, state: view }] })} + onDelete={async (id) => g.saveBoardMeta({ views: board.views.filter((x) => x.id !== id) })} /> +
+
+ +
+ {filtersOpen && ( + + )} +
+ {neighbourInfo && ( +
+ {t('graph.neighboursBanner', { name: neighbourInfo.base.name || `#${neighbourInfo.base.id}`, count: neighbourInfo.list.length })} + {neighbourInfo.list.length > 0 && } + +
+ )} + {view.gaps && !neighbourInfo && (view.mode === 'cards' || view.mode === 'table') && ( + gaps === null ? ( +
{t('graph.tooManyGaps', { n: GAP_LIMIT })}
+ ) : needGapHint ? ( +
{t('graph.gapsHint')}
+ ) : gaps && ( +
+ {t('graph.coverage', { filled: gapFilled, total: gapTotal })} +
+
+ ) + )} + + {total === 0 && !view.gaps && view.mode !== 'matrix' ? ( +
+
{t('graph.emptyBoardTitle')}
+

{t('graph.emptyBoardBody')}

+
+ + + +
+
+ ) : filtered.length === 0 && !(view.gaps && gaps?.length) && view.mode !== 'matrix' ? ( +
{t('graph.nothingMatches')} + +
+ ) : view.mode === 'cards' ? ( + setV((v) => ({ ...v, collapsed: v.collapsed.includes(key) ? v.collapsed.filter((k) => k !== key) : [...v.collapsed, key] }))} + params={params} shown={view.shownParams} thumb={view.thumb} selected={selected} actions={actions} + tasksFor={tasksFor} taskById={taskById} highlight={neighbourInfo?.highlight} /> + ) : view.mode === 'table' ? ( + setV((v) => { + const cur = v.sort[0] + const dir: SortKey['dir'] = cur?.key === key && cur.dir === 'asc' ? 'desc' : 'asc' + return { ...v, sort: [{ key, dir }, ...v.sort.filter((s) => s.key !== key)].slice(0, 4) } + })} /> + ) : view.mode === 'matrix' ? ( + + ) : ( + setOpenRunId(r.id)} /> + )} +
+ + {openRun &&
setOpenRunId(null)} aria-hidden="true" />} + {openRun && ( + setOpenRunId(null)} + onUpdate={(patch) => void g.updateRun(openRun.id, patch)} + onUpload={(files, step) => void uploadTo(openRun, files, step)} + onOpenImage={(img) => setImage({ runId: openRun.id, imageId: img.id })} + onImportSamples={(taskId) => setSamplePick({ runId: openRun.id, taskId })} + onDuplicate={() => actions.duplicate(openRun)} + onDelete={() => actions.remove(openRun)} + onCreateTraining={() => setCreateFor({ values: openRun.values, runId: openRun.id })} + onDownloadConfig={() => void downloadConfig(openRun)} + onOpenRun={(r) => setOpenRunId(r.id)} + onShowNeighbours={() => { setNeighboursOf(openRun.id); setOpenRunId(null) }} + onLinkTask={(task) => void g.updateRun(openRun.id, task + ? { task_id: task.id, project_id: task.project_id, version_id: task.version_id, status: statusFromTask(task.status) } + : { task_id: null })} + /> + )} +
+ + {selected.size > 0 && ( +
+ {t('graph.selectedN', { count: selected.size })} + + {selected.size === 1 && } + + {selectedRuns.length === 1 && {t('graph.pasteTarget')}} +
+ )} + + {compare && compareRuns.length > 0 && ( + setCompare(null)} + onOpenRun={(r) => { setCompare(null); setOpenRunId(r.id) }} + onOpenImage={(r, img) => setImage({ runId: r.id, imageId: img.id })} /> + )} + {image && imageRun && ( + setImage(null)} + onUpdate={(img: GImage, patch) => void g.updateImage(img, patch)} + onDelete={(img) => void g.deleteImage(img)} + onBestStep={(step) => void g.updateRun(imageRun.id, { best_step: step })} /> + )} + {createFor && ( + setCreateFor(null)} + onCreated={async ({ projectId, versionId, task, label: vlabel, projectTitle, values: saved, adjusted }) => { + const link = { + project_id: projectId, version_id: versionId, task_id: task?.id ?? null, + status: task ? statusFromTask(task.status) : 'planned' as const, + } + if (createFor.runId != null) { + const cur = runs.find((r) => r.id === createFor.runId) + // A card that already has a training keeps it; the new one gets its own card. + if (cur && cur.task_id == null && cur.version_id == null) await g.updateRun(cur.id, { ...link, values: saved }) + else await g.createRun({ ...link, values: saved, name: `${projectTitle} / ${vlabel}` }) + } else { + await g.createRun({ ...link, values: saved, name: `${projectTitle} / ${vlabel}` }) + } + void g.refreshTasks() + toast(task ? t('graph.trainingQueued', { label: vlabel }) : t('graph.versionCreated', { label: vlabel }), 'success') + if (adjusted.length) toast(t('graph.configAdjusted', { keys: adjusted.join(', ') }), 'error') + }} /> + )} + {paramsOpen && setParamsOpen(false)} onSave={saveParams} />} + {importOpen && ( + setImportOpen(false)} + onPick={(task) => { setImportOpen(false); void adoptTask(task) }} /> + )} + {samplePick && (() => { + const r = runs.find((x) => x.id === samplePick.runId) + return r ? ( + setSamplePick(null)} + onImport={async (names) => { + const res = await g.importSamples(r.id, samplePick.taskId, names) + if (res) toast(t('graph.importedN', { count: res.items.length }), 'success') + }} /> + ) : null + })()} + {newBoardOpen && setNewBoardOpen(false)} onCreate={createBoard} />} +
+ ) +} + +// ── toolbar helpers ───────────────────────────────────────────────────────── + +const sortValue = (k: SortKey | undefined) => (k ? `${k.key}:${k.dir}` : '_created:desc') +const parseSort = (s: string): SortKey => { + const i = s.lastIndexOf(':') + return { key: s.slice(0, i), dir: s.slice(i + 1) === 'asc' ? 'asc' : 'desc' } +} + +/** A short list: the useful built-ins plus number parameters (LR, steps…). */ +function sortOptions(params: Param[], t: (k: string, o?: Record) => string): [string, string][] { + return [ + ['_created:desc', t('graph.sortNewest')], + ['_created:asc', t('graph.sortOldest')], + ['_rating:desc', t('graph.sortBest')], + ['_name:asc', t('graph.sortName')], + ...visibleParams(params).filter((p) => p.type === 'number').map((p) => [`${p.id}:asc`, t('graph.sortByParam', { name: p.name })] as [string, string]), + ] +} + +const CheckMark = ( + +) + +/** The settings button: the less frequent view settings in one place. */ +function ViewSettings({ board, view, params, setView, onApply, onSave, onDelete }: { + board: Board + view: ViewState + params: Param[] + setView: (fn: (v: ViewState) => ViewState) => void + onApply: (s: ViewState) => void + onSave: (name: string) => Promise + onDelete: (id: string) => Promise +}) { + const { t } = useTranslation() + const dialog = useDialog() + const [open, setOpen] = useState(false) + const ref = useRef(null) + const vis = visibleParams(params) + return ( + <> + + {open && ref.current && ( + setOpen(false)}> +
+ {view.mode === 'cards' && ( + <> + + + + + )} + {(view.mode === 'cards' || view.mode === 'table') && ( + + )} + {view.mode === 'cards' && ( + <> +
{t('graph.cardFieldsTitle')}
+
+ {vis.map((p) => { + const on = view.shownParams.includes(p.id) + return ( + + ) + })} +
+ + )} +
+
{t('graph.viewsTitle')}
+ {board.views.length === 0 &&
{t('graph.noViews')}
} + {board.views.map((sv) => ( +
+ + +
+ ))} + +
+ + )} + + ) +} + +/** One primary button with the two ways to add a training. */ +function AddMenu({ onBlank, onFromQueue }: { onBlank: () => void; onFromQueue: () => void }) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const ref = useRef(null) + return ( + <> + + {open && ref.current && ( + setOpen(false)}> + setOpen(false)} items={[ + { label: <>{t('graph.addBlank')}{t('graph.addBlankSub')}, tall: true, icon: Ico.plus, onSelect: onBlank }, + { label: <>{t('graph.fromQueue')}{t('graph.fromQueueSub')}, tall: true, icon: Ico.queue, onSelect: onFromQueue }, + ]} /> + + )} + + ) +} + +function NewBoardDialog({ boards, onClose, onCreate }: { + boards: import('./graph/types').BoardSummary[] + onClose: () => void + onCreate: (name: string, mode: 'default' | 'empty' | number) => Promise +}) { + const { t } = useTranslation() + const [name, setName] = useState('') + const [mode, setMode] = useState<'default' | 'empty' | number>('default') + const [busy, setBusy] = useState(false) + return ( + + + + }> +
+ +
+ {t('graph.startWith')} +
+ + + {boards.map((b) => ( + + ))} +
+
+
+
+ ) +} diff --git a/studio/web/src/pages/tools/Monitor.tsx b/studio/web/src/pages/tools/Monitor.tsx index 7698692..645f5fe 100644 --- a/studio/web/src/pages/tools/Monitor.tsx +++ b/studio/web/src/pages/tools/Monitor.tsx @@ -18,6 +18,7 @@ import { } from '../../components/MonitorDashboard' import { useToast } from '../../components/Toast' import { evalProgressFromResults } from '../../lib/useEvalProgress' +import HelpTip from '../../components/ds/HelpTip' const METRIC_CODE: Record = { clip_t: 'CLIP-T', clip_i: 'CLIP-I', dino_i: 'DINO-I', ccip_i: 'CCIP-I', tag_recall: 'TAG RECALL', @@ -145,7 +146,6 @@ export default function MonitorPage() { return (
{taskId === null && } {tasks.map((x) => ( - + ))}
@@ -680,7 +679,7 @@ export default function PresetsPage() { {kv(t('presets.kvEpochs'), raw(summaryConfig.epochs))} {kv('Timestep', raw(summaryConfig.timestep_sampling))}
-
+
{(['yaml', 'toml'] as const).map((f) => ( + {isOpen && ( +
+ +
+ )} + + ) + })} +
+ ) +} + +const GAP = 12 + +/** Column count of the card grid, so stacks of identical runs can span + * exactly as many columns as they have cards and stay on the grid. */ +function useColumns(thumb: number) { + const ref = useRef(null) + const [cols, setCols] = useState(1) + useLayoutEffect(() => { + const el = ref.current + if (!el) return + const measure = () => setCols(Math.max(1, Math.floor((el.clientWidth + GAP) / (thumb + GAP)))) + measure() + const ro = new ResizeObserver(measure) + ro.observe(el) + return () => ro.disconnect() + }, [thumb]) + return { ref, cols } +} + +function ComboGrid({ combos, ...rest }: CardsProps & { combos: Combo[] }) { + const { ref, cols } = useColumns(rest.thumb) + return ( +
+ {combos.map((c) => + c.runs.length === 0 ? + : c.runs.length === 1 ? + : , + )} +
+ ) +} + +/** Several runs in one place: side by side on the grid, with what differs + * between them highlighted. */ +function ComboStack({ combo, cols, ...rest }: CardsProps & { combo: Combo; cols: number }) { + const { t } = useTranslation() + const diff = useMemo(() => varyingParams(combo.runs.map((r) => r.values), rest.params), [combo.runs, rest.params]) + const shown = useMemo(() => [...new Set([...diff, ...rest.shown])], [diff, rest.shown]) + const span = Math.min(combo.runs.length, cols) + return ( +
+
+ {t('graph.nRuns', { count: combo.runs.length })} + {span > 1 && {diff.length ? t('graph.stackDiffers') : t('graph.stackSame')}} + +
+
+ {combo.runs.map((r) => ( + + ))} +
+
+ ) +} + +export const RunCard = memo(function RunCard({ run, params, shown, selected, actions, taskById, highlight, diffKeys }: CardsProps & { + run: Run + diffKeys?: string[] +}) { + const { t } = useTranslation() + const label = useValueLabel() + const byId = useMemo(() => paramById(params), [params]) + const [over, setOver] = useState(false) + const navigate = useNavigate() + const cover = coverImage(run) + const task = run.task_id != null ? taskById.get(run.task_id) : undefined + const hl = highlight?.get(run.id) + const isSel = selected.has(run.id) + const chips = shown + .map((id) => byId.get(id)) + .filter((p): p is Param => !!p && !p.archived && isActive(p, run.values, byId)) + + const menu: KebabItem[] = [ + { label: t('graph.open'), onSelect: () => actions.open(run) }, + { label: t('graph.addSamples'), onSelect: () => actions.pickFiles(run) }, + { label: t('graph.duplicate'), onSelect: () => actions.duplicate(run) }, + { label: t('graph.oneAway'), onSelect: () => actions.neighbours(run) }, + { label: isSel ? t('graph.unselect') : t('graph.selectCompare'), onSelect: () => actions.toggleSelect(run) }, + { label: t('graph.createTraining'), onSelect: () => actions.createTraining(run.values, run), divider: true }, + { label: t('graph.downloadConfig'), onSelect: () => actions.downloadConfig(run) }, + { label: t('common.delete'), onSelect: () => actions.remove(run), tone: 'err', divider: true }, + ] + + return ( +
{ if (!(e.target as HTMLElement).closest('button, a, input, select, textarea')) actions.open(run) }} + onDragOver={(e) => { if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); setOver(true) } }} + onDragLeave={() => setOver(false)} + onDrop={(e) => { + e.preventDefault() + setOver(false) + const files = Array.from(e.dataTransfer.files) + if (files.length) actions.drop(run, files) + }} + > +
{ e.stopPropagation(); if (cover) actions.openImage(run, cover); else actions.open(run) }} + onKeyDown={(e) => { if (e.key === 'Enter') { if (cover) actions.openImage(run, cover); else actions.open(run) } }} + aria-label={cover ? t('graph.openFull') : t('graph.openCard', { name: run.name || `#${run.id}` })}> + {cover ? ( + <> + + {cover.step != null && {t('graph.stepN', { n: cover.step })}} + {run.images.length > 1 && {Ico.image}{run.images.length}} + + ) : ( +
+ {Ico.image} + {over ? t('graph.dropHere') : t('graph.noSamples')} +
+ )} + + +
+
+
+ + {run.name || {t('graph.untitled')}} + + +
+
+ + actions.rate(run, n)} size={13} /> +
+ {hl && ( +
{t('graph.differsIn', { name: byId.get(hl)?.name ?? hl })}
+ )} + {chips.length > 0 && ( +
+ {chips.map((p) => ( + + {p.name} + {label(p, run.values[p.id])} + + ))} +
+ )} + {task && ( + + )} +
+
+ ) +}) + +/** An untried combination: what it would be, plus ways to fill it. */ +function SlotCard({ combo, params, actions, tasksFor }: CardsProps & { combo: Combo }) { + const { t } = useTranslation() + const label = useValueLabel() + const matching = tasksFor(combo.values) + const entries = params.filter((p) => combo.values[p.id] !== undefined && !p.archived) + return ( +
+
{t('graph.emptyPlace')}
+
+ {entries.map((p) => ( + + {p.name} + {label(p, combo.values[p.id])} + + ))} +
+ {matching.length > 0 && ( +
+ {t('graph.inQueue')} +
+ {matching.slice(0, 4).map((task) => ( + + ))} + {matching.length > 4 && +{matching.length - 4}} +
+
+ )} +
+ + +
+
+ ) +} + +/** Files a user drops or pastes → step guessed from the name when present. */ +export function groupFilesByStep(files: File[]): { step: number | null; files: File[] }[] { + const map = new Map() + for (const f of files) { + const s = stepFromFilename(f.name) + map.set(s, [...(map.get(s) ?? []), f]) + } + return [...map.entries()].map(([step, fs]) => ({ step, files: fs })) +} diff --git a/studio/web/src/pages/tools/graph/CompareView.tsx b/studio/web/src/pages/tools/graph/CompareView.tsx new file mode 100644 index 0000000..f2211c2 --- /dev/null +++ b/studio/web/src/pages/tools/graph/CompareView.tsx @@ -0,0 +1,292 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import { graphApi } from './api' +import { activeValues, paramById, varyingParams } from './model' +import type { GImage, Param, Run } from './types' +import { Ico, Stars, Switch, useValueLabel } from './ui' +import HelpTip from '../../../components/ds/HelpTip' + +/** Zoom state in image fractions, so images of different sizes show the + * same region when synced. z=1 fits the whole image. */ +interface ZView { z: number; cx: number; cy: number } +const FIT: ZView = { z: 1, cx: 0.5, cy: 0.5 } + +/** Sample size presets: cell height / minimum column width. */ +const SIZES = { s: { h: 200, w: 150 }, m: { h: 300, w: 210 }, l: { h: 480, w: 320 } } as const + +type StepChoice = number | 'all' | 'best' +const ANY = '__any__' + +export default function CompareView({ runs: initial, params, onClose, onOpenRun, onOpenImage }: { + runs: Run[] + params: Param[] + onClose: () => void + onOpenRun: (r: Run) => void + onOpenImage: (run: Run, img: GImage) => void +}) { + const { t } = useTranslation() + const label = useValueLabel() + const byId = useMemo(() => paramById(params), [params]) + const [ids, setIds] = useState(() => initial.map((r) => r.id)) + const runs = ids.map((id) => initial.find((r) => r.id === id)).filter((r): r is Run => !!r) + + const diff = useMemo(() => varyingParams(runs.map((r) => r.values), params), [runs, params]) + const common = useMemo(() => { + if (!runs.length) return [] + const a = activeValues(runs[0].values, params) + return params.filter((p) => !p.archived && !diff.includes(p.id) && a[p.id] !== undefined) + }, [runs, params, diff]) + + const allImgs = runs.flatMap((r) => r.images) + const steps = [...new Set(allImgs.map((i) => i.step).filter((s): s is number => s != null))].sort((a, b) => a - b) + const prompts = [...new Set(allImgs.map((i) => i.prompt).filter(Boolean))] + const seeds = [...new Set(allImgs.map((i) => i.seed).filter((s): s is number => s != null))].sort((a, b) => a - b) + + // Default: the last step every run has; otherwise all steps as rows. + const [step, setStep] = useState(() => { + const shared = steps.filter((s) => runs.every((r) => r.images.some((i) => i.step === s))) + return shared.length ? shared[shared.length - 1] : steps.length ? 'all' : 'best' + }) + const [prompt, setPrompt] = useState(ANY) + const [seed, setSeed] = useState(ANY) + const [sync, setSync] = useState(true) + const [shared, setShared] = useState(FIT) + const [own, setOwn] = useState>({}) + const [showCommon, setShowCommon] = useState(false) + const [size, setSize] = useState(() => { + try { const v = localStorage.getItem('studio:graph:cmpSize') as keyof typeof SIZES | null; return v && v in SIZES ? v : 'm' } catch { return 'm' } + }) + const pickSize = (v: keyof typeof SIZES) => { setSize(v); try { localStorage.setItem('studio:graph:cmpSize', v) } catch { /* private mode */ } } + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !document.querySelector('.gr-viewer')) { e.stopPropagation(); onClose() } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose]) + + const pick = (r: Run, s: number | null | 'best'): GImage[] => r.images.filter((i) => { + if (s === 'best') { + if (r.best_step != null) { if (i.step !== r.best_step) return false } + } else if (i.step !== s) return false + if (prompt !== ANY && i.prompt !== prompt) return false + if (seed !== ANY && String(i.seed) !== seed) return false + return true + }) + + const hasNoStep = allImgs.some((i) => i.step == null) + const rows: (number | null | 'best')[] = step === 'all' ? [...steps, ...(hasNoStep ? [null] : [])] : step === 'best' ? ['best'] : [step] + + const viewFor = (key: string) => (sync ? shared : own[key] ?? FIT) + const setViewFor = (key: string, v: ZView) => (sync ? setShared(v) : setOwn((m) => ({ ...m, [key]: v }))) + + const cols = `repeat(${Math.max(1, runs.length)}, minmax(${SIZES[size].w}px, ${SIZES[size].w * 1.6}px))` + + return createPortal( +
+
+
+
{t('graph.compareTitle')} ({runs.length}){t('graph.compareSub')}
+
+ + + +
+ {(Object.keys(SIZES) as (keyof typeof SIZES)[]).map((k) => ( + + ))} +
+ { setSync(v); setShared(FIT); setOwn({}) }} label={t('graph.syncZoom')} /> + + +
+ +
+
+ + {showCommon && ( +
+ {common.map((p) => ( + {p.name}{label(p, runs[0].values[p.id])} + ))} +
+ )} +
+ +
+ {step === 'all' &&
} + {runs.map((r) => ( +
+
+ + + {runs.length > 2 && ( + + )} +
+
+ {diff.map((pid) => { + const p = byId.get(pid)! + const vals = activeValues(r.values, params) + return ( + + {p.name} + {vals[pid] === undefined ? {t('graph.unset')} : label(p, vals[pid])} + + ) + })} +
+
+ ))} + + {rows.map((s) => ( + + ))} +
+ {!allImgs.length &&
{t('graph.compareNoImages')}
} +
+
, + document.body, + ) +} + +function RowCells({ s, runs, withLabel, pick, viewFor, setViewFor, onOpenImage }: { + s: number | null | 'best' + runs: Run[] + withLabel: boolean + pick: (r: Run, s: number | null | 'best') => GImage[] + viewFor: (key: string) => ZView + setViewFor: (key: string, v: ZView) => void + onOpenImage: (run: Run, img: GImage) => void +}) { + const { t } = useTranslation() + return ( + <> + {withLabel &&
{s === 'best' ? t('graph.bestStep') : s == null ? t('graph.noStep') : t('graph.stepN', { n: s })}
} + {runs.map((r) => { + const imgs = pick(r, s) + if (s === 'best') imgs.sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0) || (b.step ?? -1) - (a.step ?? -1)) + const key = `${r.id}:${String(s)}` + const img = imgs[0] + return ( +
+ {img ? ( + <> + setViewFor(key, v)} /> +
+ {img.step != null && {t('graph.stepN', { n: img.step })}} + {img.seed != null && seed {img.seed}} + {imgs.length > 1 && +{imgs.length - 1}} + +
+ + ) : ( +
+ {s === 'best' || s == null ? t('graph.noSample') : t('graph.noSampleAt', { n: s })} +
+ )} +
+ ) + })} + + ) +} + +function SyncImage({ img, view, onView }: { img: GImage; view: ZView; onView: (v: ZView) => void }) { + const ref = useRef(null) + const [box, setBox] = useState({ w: 0, h: 0 }) + const [nat, setNat] = useState<{ w: number; h: number } | null>( + img.width && img.height ? { w: img.width, h: img.height } : null, + ) + const viewRef = useRef(view) + viewRef.current = view + const onViewRef = useRef(onView) + onViewRef.current = onView + const drag = useRef<{ x: number; y: number; v: ZView } | null>(null) + + useLayoutEffect(() => { + const el = ref.current + if (!el) return + const ro = new ResizeObserver(() => setBox({ w: el.clientWidth, h: el.clientHeight })) + ro.observe(el) + setBox({ w: el.clientWidth, h: el.clientHeight }) + return () => ro.disconnect() + }, []) + + const s0 = nat && box.w ? Math.min(box.w / nat.w, box.h / nat.h) : 0 + useEffect(() => { + const el = ref.current + if (!el || !nat || !s0) return + const onWheel = (e: WheelEvent) => { + e.preventDefault() + const v = viewRef.current + const r = el.getBoundingClientRect() + const mx = e.clientX - r.left - r.width / 2 + const my = e.clientY - r.top - r.height / 2 + const scale = s0 * v.z + const fx = v.cx + mx / (nat.w * scale) + const fy = v.cy + my / (nat.h * scale) + const z = Math.max(1, Math.min(24, v.z * (e.deltaY < 0 ? 1.18 : 1 / 1.18))) + const ns = s0 * z + const next = z === 1 ? FIT : { z, cx: fx - mx / (nat.w * ns), cy: fy - my / (nat.h * ns) } + // Several wheel ticks can land before React re-renders; build on the + // latest value, not the one from the last render. + viewRef.current = next + onViewRef.current(next) + } + el.addEventListener('wheel', onWheel, { passive: false }) + return () => el.removeEventListener('wheel', onWheel) + }, [nat, s0]) + + const scale = s0 * view.z + const style: React.CSSProperties = nat && s0 ? { + position: 'absolute', + width: nat.w * scale, + height: nat.h * scale, + left: box.w / 2 - view.cx * nat.w * scale, + top: box.h / 2 - view.cy * nat.h * scale, + maxWidth: 'none', + } : { width: '100%', height: '100%', objectFit: 'contain' } + // Thumbnail while fitted, the original once zoomed in. + const src = view.z > 1.3 ? graphApi.imageUrl(img.id) : graphApi.imageUrl(img.id, 1024) + + return ( +
1 ? 'grab' : 'zoom-in' }} + onPointerDown={(e) => { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); drag.current = { x: e.clientX, y: e.clientY, v: viewRef.current } }} + onPointerMove={(e) => { + const d = drag.current + if (!d || !nat || !s0 || d.v.z <= 1) return + const sc = s0 * d.v.z + onView({ ...d.v, cx: d.v.cx - (e.clientX - d.x) / (nat.w * sc), cy: d.v.cy - (e.clientY - d.y) / (nat.h * sc) }) + }} + onPointerUp={() => { drag.current = null }} + onDoubleClick={() => onView(view.z > 1 ? FIT : { z: 3, cx: 0.5, cy: 0.5 })} + > + {img.prompt} { if (!nat) setNat({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight }) }} /> +
+ ) +} diff --git a/studio/web/src/pages/tools/graph/CreateTrainingDialog.tsx b/studio/web/src/pages/tools/graph/CreateTrainingDialog.tsx new file mode 100644 index 0000000..bfa669d --- /dev/null +++ b/studio/web/src/pages/tools/graph/CreateTrainingDialog.tsx @@ -0,0 +1,217 @@ +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router-dom' +import { api, type ApiError, type ConfigData, type ProjectSummary, type Task, type Version } from '../../../api/client' +import { activeValues, configPatch, formatNumber, paramById, sameConfigValue, valuesFromConfig } from './model' +import type { Param, Values } from './types' +import { Modal, Switch, useValueLabel } from './ui' + +const LABEL_RE = /^[A-Za-z0-9_.-]+$/ + +function show(v: unknown): string { + if (v === undefined) return '—' + if (typeof v === 'number') return formatNumber(v) + if (Array.isArray(v)) return `[${v.map(show).join(', ')}]` + return String(v) +} + +/** + * New version from an empty place (or a card): copies a base version — its + * dataset, reg set and config — writes the place's parameters into the config + * and can queue it right away. The card on the board gets linked to the new + * version / task so it shows up as "in the queue". + */ +export default function CreateTrainingDialog({ values, params, onClose, onCreated }: { + values: Values + params: Param[] + onClose: () => void + onCreated: (r: { + projectId: number; versionId: number; task: Task | null; label: string; projectTitle: string + /** Card values as the saved config actually has them. */ + values: Values + /** Config keys the server changed on save (incompatible combinations). */ + adjusted: string[] + }) => Promise | void +}) { + const { t } = useTranslation() + const navigate = useNavigate() + const label = useValueLabel() + const byId = useMemo(() => paramById(params), [params]) + const [projects, setProjects] = useState(null) + const [pid, setPid] = useState(null) + const [versions, setVersions] = useState([]) + const [baseVid, setBaseVid] = useState(null) + const [baseCfg, setBaseCfg] = useState(null) + const [cfgState, setCfgState] = useState<'idle' | 'loading' | 'none' | 'ok'>('idle') + const [newLabel, setNewLabel] = useState('') + const [queue, setQueue] = useState(true) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + const act = activeValues(values, params) + const { patch, skipped } = useMemo(() => configPatch(values, params), [values, params]) + + useEffect(() => { + api.listProjects().then((ps) => { + const live = ps.filter((p) => !p.archived_at) + setProjects(live) + if (live.length) setPid(live[0].id) + }).catch(() => setProjects([])) + }, []) + + useEffect(() => { + if (pid == null) return + let alive = true + setVersions([]) + setBaseVid(null) + api.listVersions(pid).then((vs) => { + if (!alive) return + setVersions(vs) + const proj = projects?.find((p) => p.id === pid) + const def = vs.find((v) => v.id === proj?.active_version_id) ?? vs[vs.length - 1] + setBaseVid(def?.id ?? null) + }).catch(() => { if (alive) setVersions([]) }) + return () => { alive = false } + }, [pid, projects]) + + useEffect(() => { + if (pid == null || baseVid == null) { setBaseCfg(null); setCfgState('idle'); return } + let alive = true + setCfgState('loading') + api.getVersionConfig(pid, baseVid).then((r) => { + if (!alive) return + setBaseCfg(r.config) + setCfgState(r.has_config && r.config ? 'ok' : 'none') + }).catch(() => { if (alive) { setBaseCfg(null); setCfgState('none') } }) + const base = versions.find((v) => v.id === baseVid) + if (base) { + const taken = new Set(versions.map((v) => v.label)) + let n = 1 + while (taken.has(`${base.label}-g${n}`)) n++ + setNewLabel(`${base.label}-g${n}`) + } + return () => { alive = false } + }, [pid, baseVid, versions]) + + const labelError = !newLabel ? t('graph.labelRequired') + : !LABEL_RE.test(newLabel) ? t('graph.labelInvalid') + : versions.some((v) => v.label === newLabel) ? t('graph.labelTaken') : null + + const changes = Object.entries(patch).map(([k, v]) => ({ key: k, from: baseCfg?.[k], to: v })) + const canCreate = pid != null && baseVid != null && cfgState === 'ok' && !labelError && !busy + + const create = async () => { + if (pid == null || baseVid == null) return + setBusy(true) + setError(null) + let created: Version | null = null + let stage: 'version' | 'config' | 'queue' = 'version' + const proj = projects?.find((p) => p.id === pid) + let saved: ConfigData = {} + const done = (task: Task | null) => { + const adjusted = Object.keys(patch).filter((k) => !sameConfigValue(saved[k], patch[k])) + return onCreated({ + projectId: pid, versionId: created!.id, task, label: newLabel, projectTitle: proj?.title ?? '', + values: { ...values, ...valuesFromConfig(saved, params) }, adjusted, + }) + } + try { + created = await api.createVersion(pid, { label: newLabel, fork_from_version_id: baseVid, note: t('graph.versionNote') }) + stage = 'config' + let cfg = (await api.getVersionConfig(pid, created.id)).config ?? {} + const family = patch.model_family + if (typeof family === 'string' && family !== cfg.model_family) { + cfg = (await api.switchModelFamily(family, cfg)).config + } + saved = (await api.putVersionConfig(pid, created.id, { ...cfg, ...patch })).config + stage = 'queue' + const task = queue ? await api.enqueueVersionTraining(pid, created.id) : null + await done(task) + onClose() + } catch (e) { + const msg = (e as ApiError)?.message || t('graph.createFailed') + if (created && stage === 'config') { + // The config was rejected: remove the half-made version so it doesn't linger. + await api.deleteVersion(pid, created.id).catch(() => undefined) + setError(msg) + } else if (created && stage === 'queue') { + // The version is fine, only queueing failed: keep it and link the card. + await done(null) + setError(t('graph.queueFailed', { msg })) + } else { + setError(msg) + } + } finally { + setBusy(false) + } + } + + return ( + + + + }> +
+
+ {params.filter((p) => act[p.id] !== undefined).map((p) => ( + {p.name}{label(p, act[p.id])} + ))} + {!Object.keys(act).length && {t('graph.noValuesSet')}} +
+ + {projects && projects.length === 0 ? ( +
{t('graph.noProjects')} +
+ ) : ( + <> + + + {cfgState === 'none' &&
{t('graph.baseNoConfig')}
} + + + {cfgState === 'ok' && ( +
+ {t('graph.configChanges')} +
+ {changes.length === 0 && {t('graph.noConfigChanges')}} + {changes.map((c) => { + const same = JSON.stringify(c.from) === JSON.stringify(c.to) + return ( +
+ {c.key} + {same ? show(c.to) : <>{show(c.from)} → {show(c.to)}} +
+ ) + })} +
+
+ )} + {skipped.length > 0 && ( +
{t('graph.notApplied', { names: skipped.map((id) => byId.get(id)?.name ?? id).join(', ') })}
+ )} + + + )} + {error &&
{error}
} +
+
+ ) +} diff --git a/studio/web/src/pages/tools/graph/FilterPanel.tsx b/studio/web/src/pages/tools/graph/FilterPanel.tsx new file mode 100644 index 0000000..b4d370a --- /dev/null +++ b/studio/web/src/pages/tools/graph/FilterPanel.tsx @@ -0,0 +1,187 @@ +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { filterIsSet, formatNumber, isActive, knownValues, paramById, parseNumber, sameValue, visibleParams } from './model' +import type { Param, ParamFilter, Run, RunValue, ViewState } from './types' +import { RUN_STATUSES } from './types' +import { Ico, Switch, useValueLabel } from './ui' + +type SetView = (fn: (v: ViewState) => ViewState) => void + +/** + * Left column. Every parameter is one collapsed row showing what's picked; + * opening it lists the values as checkboxes with how many cards use each — + * a 0 is a value that hasn't been tried yet. + */ +export default function FilterPanel({ params, runs, view, setView }: { + params: Param[] + runs: Run[] + view: ViewState + setView: SetView +}) { + const { t } = useTranslation() + const label = useValueLabel() + const byId = useMemo(() => paramById(params), [params]) + // Sections with an active filter start open; the rest stay folded. + const [open, setOpen] = useState>(() => new Set(Object.keys(view.filters))) + const toggle = (id: string) => setOpen((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n }) + + const setFilter = (id: string, fn: (f: ParamFilter) => ParamFilter) => setView((v) => { + const next = fn(v.filters[id] ?? {}) + const filters = { ...v.filters } + if (filterIsSet(next)) filters[id] = next + else delete filters[id] + return { ...v, filters } + }) + + const anySet = view.statuses.length > 0 || view.samples !== 'any' || view.favorites || view.minRating > 0 + || Object.values(view.filters).some(filterIsSet) + + const statusSummary = view.statuses.map((s) => t(`graph.status_${s}`)).join(', ') + const moreSummary = [ + view.favorites && t('graph.fFavorites'), + view.samples === 'with' && t('graph.fWithSamples'), + view.samples === 'without' && t('graph.fWithoutSamples'), + view.minRating > 0 && `★ ${view.minRating}+`, + ].filter(Boolean).join(', ') + + return ( +
+
+ {t('graph.filters')} + {anySet && ( + + )} +
+ + {(view.mode === 'cards' || view.mode === 'table') && ( +
+ setView((v) => ({ ...v, gaps: on }))} label={t('graph.showGaps')} /> + {t('graph.showGapsHint')} +
+ )} + +
+ {RUN_STATUSES.map((s) => ( + r.status === s).length} + onClick={() => setView((v) => ({ ...v, statuses: v.statuses.includes(s) ? v.statuses.filter((x) => x !== s) : [...v.statuses, s] }))}> + {t(`graph.status_${s}`)} + + ))} +
+ +
+ r.favorite).length} onClick={() => setView((v) => ({ ...v, favorites: !v.favorites }))}> + {t('graph.fFavorites')} + + r.images.length).length} + onClick={() => setView((v) => ({ ...v, samples: v.samples === 'with' ? 'any' : 'with' }))}>{t('graph.fWithSamples')} + !r.images.length).length} + onClick={() => setView((v) => ({ ...v, samples: v.samples === 'without' ? 'any' : 'without' }))}>{t('graph.fWithoutSamples')} + {[3, 2, 1].map((n) => ( + (r.rating ?? 0) >= n).length} + onClick={() => setView((v) => ({ ...v, minRating: v.minRating === n ? 0 : n }))}> + {'★'.repeat(n)}{'★'.repeat(3 - n)}{n < 3 ? ` ${t('graph.andHigher')}` : ''} + + ))} +
+ +
+ + {visibleParams(params).map((p) => { + const f = view.filters[p.id] + const valueOf = (r: Run) => (isActive(p, r.values, byId) ? r.values[p.id] : undefined) + const count = (v: RunValue | undefined) => runs.filter((r) => (v === undefined ? valueOf(r) === undefined : sameValue(valueOf(r), v))).length + const parts: string[] = [...(f?.values ?? []).map((v) => label(p, v))] + if (f?.unset) parts.push(t('graph.unset')) + if (f?.min != null || f?.max != null) parts.push(`${f?.min != null ? formatNumber(f.min) : '…'}–${f?.max != null ? formatNumber(f.max) : '…'}`) + if (f?.text?.trim()) parts.push(`“${f.text.trim()}”`) + const unset = p.type === 'text' ? 0 : count(undefined) + return ( +
setFilter(p.id, () => ({})) : undefined}> + {p.type === 'text' ? ( + setFilter(p.id, (x) => ({ ...x, text: e.target.value }))} /> + ) : ( + <> + {knownValues(p, runs).map((val) => { + const on = !!f?.values?.some((x) => sameValue(x, val)) + return ( + setFilter(p.id, (x) => ({ ...x, values: on ? (x.values ?? []).filter((y) => !sameValue(y, val)) : [...(x.values ?? []), val] }))}> + {label(p, val)} + + ) + })} + {unset > 0 && ( + setFilter(p.id, (x) => ({ ...x, unset: !x.unset }))}> + {t('graph.unset')} + + )} + {p.type === 'number' && ( + setFilter(p.id, (x) => ({ ...x, min, max }))} /> + )} + + )} +
+ ) + })} +
+ ) +} + +function Section({ id, title, summary, hint, gate, open, onToggle, onClear, children }: { + id: string; title: string; summary: string; hint?: string; gate?: string + open: boolean; onToggle: (id: string) => void; onClear?: () => void; children: React.ReactNode +}) { + const { t } = useTranslation() + return ( +
+ + {summary && onClear && ( + + )} + {open &&
{children}
} +
+ ) +} + +function Check({ on, onClick, count, children }: { on: boolean; onClick: () => void; count?: number; children: React.ReactNode }) { + return ( + + ) +} + +function RangeRow({ f, onChange }: { f: ParamFilter | undefined; onChange: (min: number | null, max: number | null) => void }) { + const { t } = useTranslation() + const [min, setMin] = useState(f?.min != null ? formatNumber(f.min) : '') + const [max, setMax] = useState(f?.max != null ? formatNumber(f.max) : '') + const commit = () => onChange(parseNumber(min), parseNumber(max)) + return ( +
+ setMin(e.target.value)} onBlur={commit} + onKeyDown={(e) => { if (e.key === 'Enter') commit() }} aria-label={t('graph.from')} /> + – + setMax(e.target.value)} onBlur={commit} + onKeyDown={(e) => { if (e.key === 'Enter') commit() }} aria-label={t('graph.to')} /> +
+ ) +} diff --git a/studio/web/src/pages/tools/graph/ImageDialog.tsx b/studio/web/src/pages/tools/graph/ImageDialog.tsx new file mode 100644 index 0000000..83b424b --- /dev/null +++ b/studio/web/src/pages/tools/graph/ImageDialog.tsx @@ -0,0 +1,116 @@ +import { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import ZoomableImage from '../../../components/ZoomableImage' +import { useDialog } from '../../../components/Dialog' +import { graphApi } from './api' +import { parseNumber } from './model' +import type { GImage, Run } from './types' +import { Ico, Stars } from './ui' + +/** Full-size viewer (whole image, zoom / pan) with the sample's metadata. */ +export default function ImageDialog({ run, imageId, onClose, onUpdate, onDelete, onBestStep }: { + run: Run + imageId: number + onClose: () => void + onUpdate: (img: GImage, patch: Partial) => void + onDelete: (img: GImage) => void + onBestStep: (step: number | null) => void +}) { + const { t } = useTranslation() + const dialog = useDialog() + const list = [...run.images].sort((a, b) => (a.step ?? 1e12) - (b.step ?? 1e12) || a.sort - b.sort) + const [id, setId] = useState(imageId) + const idx = Math.max(0, list.findIndex((i) => i.id === id)) + const img = list[idx] + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement | null)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + if (e.key === 'Escape') { e.stopPropagation(); onClose() } + if (e.key === 'ArrowRight' && idx < list.length - 1) setId(list[idx + 1].id) + if (e.key === 'ArrowLeft' && idx > 0) setId(list[idx - 1].id) + } + window.addEventListener('keydown', onKey, true) + return () => window.removeEventListener('keydown', onKey, true) + }, [idx, list, onClose]) + + useEffect(() => { if (!img) onClose() }, [img, onClose]) + if (!img) return null + + return createPortal( +
+
+ + {idx > 0 && } + {idx < list.length - 1 && } +
+ +
, + document.body, + ) +} + +function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { + return ( + + ) +} + +function MetaInput({ value, onSave, area, mono }: { value: string; onSave: (s: string) => void; area?: boolean; mono?: boolean }) { + const [v, setV] = useState(value) + useEffect(() => setV(value), [value]) + const common = { + value: v, + onChange: (e: React.ChangeEvent) => setV(e.target.value), + onBlur: () => { if (v !== value) onSave(v) }, + } + return area + ?