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 (