Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions studio/api/routers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
| `events_sse.py` | 1 | /api/events SSE |
| `announcements.py` | 1 | /api/announcements(公告栏,docs/announcements/ 派生) |
| `root.py` | 3 | / → SPA index;/studio、/studio/{rest} → / 兼容跳转(ADR 0012) |
| `samples.py` | 1 | /samples/{filename} |
| `samples.py` | 2 | /samples/{filename} + /api/queue/{task_id}/samples(采样图清单) |
| `logs.py` | 1 | /api/logs/{task_id} |
| `data_exports.py` | 1 | /api/data-exports |
| `tagger.py` | 1 | /api/tagger/{name}/check |
Expand All @@ -23,10 +23,10 @@
| `installs.py` | 10 | wd14 + torch + flash-attn + xformers + llm-tagger admin |
| `system.py` | 9 | restart / update / rollback / preflight / dev_commits / init_git |
| `generate.py` | 8 | 测试出图 + daemon 控制 + TAEFlux |
| `queue/` | 20 | 内拆 lifecycle (12) / io (3) / outputs (5) |
| `queue/` | 21 | 内拆 lifecycle (13) / io (3) / outputs (5) |
| `projects/` | 71 | 内拆 crud (16) / exports (6) / ingestion (14) / curation (12) / training (23) |

**合计**:约 156 routes(+ 5 非 APIRoute:SPA mount + openapi/docs/redoc 等)。route 三元组精确集见 `tests/_snapshots/studio_routes.json`(snapshot 测试守门)。
**合计**:约 158 routes(+ 5 非 APIRoute:SPA mount + openapi/docs/redoc 等)。route 三元组精确集见 `tests/_snapshots/studio_routes.json`(snapshot 测试守门)。

## 子包内部 helpers

Expand Down
34 changes: 32 additions & 2 deletions studio/api/routers/queue/lifecycle.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Queue 任务生命周期(PR-6 commit 6 从 server.py 抽出)。

13 routes:
14 routes:
GET /api/queue list(默认隐藏 generate / reg_ai)
POST /api/queue enqueue(按 preset 名,可带 scheduled_at 定时)
POST /api/queue/{task_id}/start_now scheduled 手动提前转 pending(0.17 P-B)
Expand All @@ -9,6 +9,7 @@
POST /api/queue/release 恢复调度
POST /api/queue/reorder 按 id 列表重排
GET /api/queue/{task_id} task DB 行(含 is_pausable / is_resumable 信号)
PUT /api/queue/{task_id}/note 任务备注(v20 tasks.note;右键 / 详情页写)
POST /api/queue/{task_id}/cancel
POST /api/queue/{task_id}/pause ADR 0006 §4.1
POST /api/queue/{task_id}/resume ADR 0006 §6 路径 A + Addendum 2(paused/failed/canceled)
Expand All @@ -23,7 +24,7 @@
from fastapi import APIRouter, HTTPException

from ...deps import _supervisor
from ...schemas.queue import EnqueueRequest, ReorderRequest
from ...schemas.queue import EnqueueRequest, ReorderRequest, TaskNoteBody
from .... import db
from ....domain.errors import (
ConflictError,
Expand Down Expand Up @@ -100,6 +101,9 @@ def _enrich_tasks(items: list[dict[str, Any]]) -> None:
# 0.17 P-E — history 分页 page_size 上限,防一次拉爆。
_MAX_PAGE_SIZE = 100

# 备注长度上限 —— UI 是「一句话便签」,不是日志。超长截断而不是 422。
_MAX_NOTE_LEN = 500


@router.get("/api/queue")
def list_queue(
Expand Down Expand Up @@ -299,6 +303,32 @@ def get_queue_item(task_id: int) -> dict[str, Any]:
return task


@router.put("/api/queue/{task_id}/note")
def set_task_note(task_id: int, body: TaskNoteBody) -> dict[str, Any]:
"""写 / 清 任务备注(v20 tasks.note)。

队列页右键任一行写,任务详情页 overview 也能编辑。纯 UI 元数据:任何
状态的 task 都能改(跑完很久的历史任务也能补一句「这个最好用」)。
空串 / 纯空白 → 清空(存 NULL)。
"""
raw = (body.note or "").strip()
note = raw[:_MAX_NOTE_LEN] or None
with db.connection_for() as conn:
if not db.get_task(conn, task_id):
raise NotFoundError(
"Task not found", code="task.not_found", details={"task_id": task_id}
)
db.update_task(conn, task_id, note=note)
task = db.get_task(conn, task_id)
# 队列页 / 详情页可能同时开着 —— 复用 task_state_changed 让两边都刷新。
bus.publish({
"type": "task_state_changed",
"task_id": task_id,
"status": (task or {}).get("status"),
})
return task or {"id": task_id, "note": note}


@router.post("/api/queue/{task_id}/cancel")
def cancel_task(task_id: int) -> dict[str, Any]:
if not _supervisor().cancel(task_id):
Expand Down
148 changes: 114 additions & 34 deletions studio/api/routers/samples.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
"""采样图代理(PR-6 commit 1 从 server.py 抽出)。

1 route:
GET /samples/{filename} 带 task_id 时按 monitor_state_path 多候选目录解析;
不给走全局 OUTPUT_DIR/samples/ 兜底;可选 ?w=N 缩略图
2 routes:
GET /samples/{filename} 带 task_id 时按 monitor_state_path 多候选目录解析;
不给走全局 OUTPUT_DIR/samples/ 兜底;可选 ?w=N 缩略图
GET /api/queue/{task_id}/samples 某 task 的采样图清单(队列页内联采样条用)
"""
from __future__ import annotations

import logging
import re
from pathlib import Path
from typing import Optional
from typing import Any, Optional

from fastapi import APIRouter
from fastapi.responses import FileResponse
Expand All @@ -18,27 +20,72 @@
from ... import db
from ...domain.errors import NotFoundError
from ...paths import OUTPUT_DIR, task_samples_dir
from ...services.dataset.scan import IMAGE_EXTS

router = APIRouter()
logger = logging.getLogger(__name__)

# 队列页每行内联的采样条上限。monitor state 自己 cap 50(train_monitor.py),
# 这里扫盘能看到全部历史图,给同一个数量级的上限防超长训练一次吐几百条。
_MAX_LIST = 200

_EPOCH_RE = re.compile(r"^epoch_(\d+)", re.IGNORECASE)
_STEP_RE = re.compile(r"^step_(\d+)", re.IGNORECASE)

@router.get("/samples/{filename}")
def get_sample(
filename: str,
task_id: Optional[int] = None,
w: Optional[int] = None,
) -> FileResponse:
"""采样图代理。

`?task_id=N` 给了 → 按多个候选目录查找:
def _sample_dirs(monitor_state_path: str, task_id: int) -> list[Path]:
"""某 task 的采样图目录候选,按新→旧布局排序。

- **新(task-scoped)** `studio_data/tasks/<task_id>/samples/`
- `monitor_state.json` 同级 `samples/`(PP6.1 v0.5.0+ 老 task 兼容;
state file 在 versions/<v>/monitor/task_<id>/ 时,samples 也在那)
- `monitor_state.json` 同级 `output/samples/`(pre-PP6.1 老 task;
sample_dir = output_dir/samples,output_dir 通常是 versions/{label}/output)
- 同级 `output/<任意子目录>/samples/`(兜底防 anima_train 用别的 output 名)
"""
monitor_dir = Path(monitor_state_path).parent
dirs = [
task_samples_dir(task_id),
monitor_dir / "samples",
monitor_dir / "output" / "samples",
]
output_root = monitor_dir / "output"
if output_root.is_dir():
for sub in sorted(output_root.iterdir()):
if sub.is_dir():
dirs.append(sub / "samples")
return dirs


def _monitor_state_path(task_id: int) -> Optional[str]:
with db.connection_for() as conn:
row = conn.execute(
"SELECT monitor_state_path FROM tasks WHERE id = ?", (task_id,)
).fetchone()
if not row or not row["monitor_state_path"]:
return None
return str(row["monitor_state_path"])


def _marks(filename: str) -> tuple[Optional[int], Optional[int]]:
"""从文件名解析 (epoch, step)。`epoch_3_xxx.png` / `step_1200_xxx.png`。"""
ep = _EPOCH_RE.match(filename)
st = _STEP_RE.match(filename)
return (
int(ep.group(1)) if ep else None,
int(st.group(1)) if st else None,
)


@router.get("/samples/{filename}")
def get_sample(
filename: str,
task_id: Optional[int] = None,
w: Optional[int] = None,
) -> FileResponse:
"""采样图代理。

`?task_id=N` 给了 → 按 `_sample_dirs()` 的候选目录逐个查找。
没给 task_id → 兜底全局 OUTPUT_DIR/samples/(旧训练直接命令行的兼容)。

`?w=N` 给了 → 走 thumb_cache 生成 N px 缩略图(用于监控页缩略图条);
Expand All @@ -49,29 +96,10 @@ def get_sample(

resolved: Optional[Path] = None
if task_id is not None:
with db.connection_for() as conn:
row = conn.execute(
"SELECT monitor_state_path FROM tasks WHERE id = ?",
(task_id,),
).fetchone()
if not row or not row["monitor_state_path"]:
state_path = _monitor_state_path(task_id)
if not state_path:
raise NotFoundError("Sample image not found", code="sample.not_found")
monitor_dir = Path(row["monitor_state_path"]).parent
candidates = [
# task-scoped 档案 —— 新 task 写在这(migration 之后唯一目标)
task_samples_dir(task_id) / filename,
# 老 task 兼容:state.json 同级 samples/(PP6.1 v0.5.0+ +
# c3961d9 unreleased dev 期间的过渡布局)
monitor_dir / "samples" / filename,
# 老 task 兼容:output/samples/(pre-PP6.1)
monitor_dir / "output" / "samples" / filename,
]
# 再扫一层 output/<sub>/samples/ 兜底(用户改 output_dir 名字时仍能找到)
output_root = monitor_dir / "output"
if output_root.is_dir():
for sub in output_root.iterdir():
if sub.is_dir():
candidates.append(sub / "samples" / filename)
candidates = [d / filename for d in _sample_dirs(state_path, task_id)]
for p in candidates:
if p.exists():
resolved = p
Expand All @@ -96,3 +124,55 @@ def get_sample(
immutable = task_id is not None
size = w if (w is not None and w > 0) else 0
return _thumb_response(resolved, size, immutable=immutable)


@router.get("/api/queue/{task_id}/samples")
def list_task_samples(task_id: int) -> dict[str, Any]:
"""某 task 的采样图清单,按 mtime 升序(训练时间轴)。

队列页每行的内联采样条 + 灯箱用。刻意**扫盘**而不是读 monitor_state.json:
- 已结束的 task 也能看(state 里的 samples 数组还在,但读整个 state 文件
为了拿 50 条路径太重——10k 步训练的 losses 数组几 MB);
- 扫盘能看到全部历史图,不受 monitor 那边 cap 50 的限制。

task 不存在 / 没 monitor_state_path / 目录还没建 → `{"items": []}`,不报错
(队列里一堆非训练任务,前端逐行请求,404 只会刷红控制台)。
"""
state_path = _monitor_state_path(task_id)
if not state_path:
return {"items": [], "total": 0}

seen: set[str] = set()
found: list[tuple[float, str, int]] = [] # (mtime, filename, size)
for d in _sample_dirs(state_path, task_id):
if not d.is_dir():
continue
for f in d.iterdir():
if not f.is_file() or f.suffix.lower() not in IMAGE_EXTS:
continue
if f.name in seen:
continue # 老/新布局同名文件:先命中的目录优先(同 get_sample 顺序)
seen.add(f.name)
try:
stat = f.stat()
except OSError:
continue
found.append((stat.st_mtime, f.name, stat.st_size))

found.sort(key=lambda r: (r[0], r[1]))
total = len(found)
# 超上限时保留**最新**的一批(用户要看的是训练最近长什么样)。
if total > _MAX_LIST:
found = found[-_MAX_LIST:]

items = []
for mtime, name, size in found:
epoch, step = _marks(name)
items.append({
"filename": name,
"mtime": mtime,
"size": size,
"epoch": epoch,
"step": step,
})
return {"items": items, "total": total}
8 changes: 8 additions & 0 deletions studio/api/schemas/queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,11 @@ class ExportOutputsBody(BaseModel):

class DeleteOutputsBody(BaseModel):
files: list[str]


class TaskNoteBody(BaseModel):
"""PUT /api/queue/{task_id}/note —— 队列任务备注(v20 tasks.note)。

`note` 传空串 / 只有空白 → 清除备注(存 NULL)。
"""
note: Optional[str] = None
2 changes: 2 additions & 0 deletions studio/infrastructure/migrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from ._v17_job_created_at import migrate as _migrate_v17
from ._v18_unified_ledger import migrate as _migrate_v18
from ._v19_legacy_jobs_freeze import migrate as _migrate_v19
from ._v20_task_note import migrate as _migrate_v20

Migration = Callable[[sqlite3.Connection], None]

Expand All @@ -59,6 +60,7 @@
_migrate_v17, # 上游 v16: project_jobs.created_at(0.17 P-G 数据作业详情页入队时间)
_migrate_v18, # 上游 v17: tasks.params(R-2 台账合并——tasks 承接数据作业 kind 参数)
_migrate_v19, # 上游 v18: 冻结旧 project_jobs(R-3 写路径翻转,残留 pending/running→canceled)
_migrate_v20, # v20: tasks.note(队列任务备注,右键写、详情页可编辑)
]


Expand Down
17 changes: 17 additions & 0 deletions studio/infrastructure/migrations/_v20_task_note.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""v19 → v20: tasks.note —— 队列任务备注。

加列 `tasks.note TEXT`(NULL / 空串 = 没备注)。用户在队列页右键任一任务写
一句话("这次试了 alpha=16"、"数据集换了 v3"),备注跟着 task 走:队列列表
行上显示徽标,任务详情页 overview 顶部完整显示 + 可编辑。

纯 UI 元数据,不参与调度,也不进 config snapshot。
"""
from __future__ import annotations

import sqlite3

from ._v2_projects import _add_column_if_missing


def migrate(conn: sqlite3.Connection) -> None:
_add_column_if_missing(conn, "tasks", "note", "note TEXT")
23 changes: 23 additions & 0 deletions studio/web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1802,6 +1802,20 @@ export interface Task {
/** ADR 0006 Addendum 2 — is_resumable 信号:status ∈ paused/failed/canceled
* 且恢复点文件在盘上。UI 用来决定是否显示"继续训练"按钮。 */
is_resumable?: boolean
/** _v20 — 用户手写的任务备注(队列页右键写 / 详情页编辑)。空 = null。 */
note?: string | null
}

/** GET /api/queue/{id}/samples 的一行 —— 训练采样图(扫盘得到,含已结束任务)。 */
export interface TaskSample {
filename: string
/** 文件 mtime(unix 秒),清单按它升序 = 训练时间轴。 */
mtime: number
size: number
/** 从文件名解析;`epoch_N_*.png` 才有。 */
epoch: number | null
/** 从文件名解析;`step_N_*.png` 才有。 */
step: number | null
}

/** 0.17 P-E — /api/queue?group=history 的分页响应。 */
Expand Down Expand Up @@ -3064,6 +3078,15 @@ export const api = {
req<{ task_id: number; canceled: boolean }>(`/api/queue/${id}/cancel`, {
method: 'POST',
}),
/** _v20 — 写 / 清 任务备注。空串 → 清空(后端存 NULL)。任何状态都能改。 */
setTaskNote: (id: number, note: string) =>
req<Task>(`/api/queue/${id}/note`, {
method: 'PUT',
body: JSON.stringify({ note }),
}),
/** 某 task 的训练采样图清单(扫盘,已结束任务也有)。队列页内联采样条用。 */
listTaskSamples: (id: number) =>
req<{ items: TaskSample[]; total: number }>(`/api/queue/${id}/samples`),
/** 0.17 P-B — scheduled task 手动提前:立即转 pending 参与调度。非 scheduled 409。 */
startTaskNow: (id: number) =>
req<{ task_id: number; status: string }>(`/api/queue/${id}/start_now`, {
Expand Down
Loading
Loading