From 390a093863096f7a476146e349d5b33f29bf2928 Mon Sep 17 00:00:00 2001 From: dualchimerra Date: Sun, 20 Sep 2026 10:24:53 +0200 Subject: [PATCH] feat(queue): inline sample feed + per-task notes in the queue list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 队列列表现在能直接翻每次训练的采样图,右键给任务贴备注。 采样条:每个带 monitor_state_path 的任务行底下铺一条横向缩略图带,点任一张 开灯箱(← / → 在本任务的采样序列里前后切)。取数走新的 `GET /api/queue/{id}/samples`——扫盘而不是读 monitor state,所以已结束的任务 也有图、也不受 monitor 那边 cap 50 的限制。行滚进视口才发请求( IntersectionObserver),running 任务订阅 monitor_progress 出新图自动追加。 备注:新加 `tasks.note`(migration v20)+ `PUT /api/queue/{id}/note`。队列页 右键任一行写一句话,备注显示在行上,训练详情页顶部完整显示且就地可改。 Co-Authored-By: Claude Opus 5 --- studio/api/routers/__init__.py | 6 +- studio/api/routers/queue/lifecycle.py | 34 ++- studio/api/routers/samples.py | 148 ++++++++++--- studio/api/schemas/queue.py | 8 + studio/infrastructure/migrations/__init__.py | 2 + .../migrations/_v20_task_note.py | 17 ++ studio/web/src/api/client.ts | 23 ++ .../src/components/TaskSampleStrip.test.tsx | 77 +++++++ studio/web/src/components/TaskSampleStrip.tsx | 204 ++++++++++++++++++ studio/web/src/i18n/locales/en.json | 18 +- studio/web/src/i18n/locales/ru.json | 18 ++ studio/web/src/i18n/locales/zh.json | 18 +- studio/web/src/pages/Queue.test.tsx | 118 ++++++++++ studio/web/src/pages/Queue.tsx | 199 ++++++++++++++++- studio/web/src/pages/QueueDetail.tsx | 101 +++++++++ tests/_snapshots/studio_routes.json | 18 +- tests/test_task_note_and_samples.py | 173 +++++++++++++++ 17 files changed, 1134 insertions(+), 48 deletions(-) create mode 100644 studio/infrastructure/migrations/_v20_task_note.py create mode 100644 studio/web/src/components/TaskSampleStrip.test.tsx create mode 100644 studio/web/src/components/TaskSampleStrip.tsx create mode 100644 studio/web/src/pages/Queue.test.tsx create mode 100644 tests/test_task_note_and_samples.py diff --git a/studio/api/routers/__init__.py b/studio/api/routers/__init__.py index 33adde4..ef49db5 100644 --- a/studio/api/routers/__init__.py +++ b/studio/api/routers/__init__.py @@ -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 | @@ -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 diff --git a/studio/api/routers/queue/lifecycle.py b/studio/api/routers/queue/lifecycle.py index 2c913ef..bdf6ed9 100644 --- a/studio/api/routers/queue/lifecycle.py +++ b/studio/api/routers/queue/lifecycle.py @@ -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) @@ -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) @@ -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, @@ -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( @@ -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): diff --git a/studio/api/routers/samples.py b/studio/api/routers/samples.py index bd261c9..b868fd6 100644 --- a/studio/api/routers/samples.py +++ b/studio/api/routers/samples.py @@ -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 @@ -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//samples/` - `monitor_state.json` 同级 `samples/`(PP6.1 v0.5.0+ 老 task 兼容; state file 在 versions//monitor/task_/ 时,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 缩略图(用于监控页缩略图条); @@ -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//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 @@ -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} diff --git a/studio/api/schemas/queue.py b/studio/api/schemas/queue.py index 9f4d3de..d3c6d3d 100644 --- a/studio/api/schemas/queue.py +++ b/studio/api/schemas/queue.py @@ -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 diff --git a/studio/infrastructure/migrations/__init__.py b/studio/infrastructure/migrations/__init__.py index 8041a9a..81a5f13 100644 --- a/studio/infrastructure/migrations/__init__.py +++ b/studio/infrastructure/migrations/__init__.py @@ -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] @@ -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(队列任务备注,右键写、详情页可编辑) ] diff --git a/studio/infrastructure/migrations/_v20_task_note.py b/studio/infrastructure/migrations/_v20_task_note.py new file mode 100644 index 0000000..e9bde34 --- /dev/null +++ b/studio/infrastructure/migrations/_v20_task_note.py @@ -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") diff --git a/studio/web/src/api/client.ts b/studio/web/src/api/client.ts index 70fd8ec..d0fd2f8 100644 --- a/studio/web/src/api/client.ts +++ b/studio/web/src/api/client.ts @@ -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 的分页响应。 */ @@ -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(`/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`, { diff --git a/studio/web/src/components/TaskSampleStrip.test.tsx b/studio/web/src/components/TaskSampleStrip.test.tsx new file mode 100644 index 0000000..8734d8b --- /dev/null +++ b/studio/web/src/components/TaskSampleStrip.test.tsx @@ -0,0 +1,77 @@ +/** 队列行内联采样条:清单 → 缩略图 → 点开灯箱(含前后切)。 */ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +import { api, type TaskSample } from '../api/client' +import TaskSampleStrip from './TaskSampleStrip' + +class FakeEventSource { + static readonly OPEN = 1 + onopen: (() => void) | null = null + onmessage: ((e: { data: string }) => void) | null = null + onerror: (() => void) | null = null + readyState = FakeEventSource.OPEN + close(): void { this.readyState = 2 } +} + +function sample(over: Partial = {}): TaskSample { + return { filename: 'epoch_1_a.png', mtime: 1, size: 10, epoch: 1, step: null, ...over } +} + +beforeEach(() => { + vi.stubGlobal('EventSource', FakeEventSource) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +it('renders one thumbnail per sample', async () => { + vi.spyOn(api, 'listTaskSamples').mockResolvedValue({ + items: [sample(), sample({ filename: 'epoch_2_a.png', epoch: 2, mtime: 2 })], + total: 2, + }) + render() + + const strip = await screen.findByTestId('sample-strip-7') + await waitFor(() => expect(strip.querySelectorAll('img')).toHaveLength(2)) + // 缩略图走 /samples/{file}?task_id=&w= 代理(带 task_id,否则落全局兜底目录) + expect(strip.querySelector('img')!.getAttribute('src')) + .toContain('/samples/epoch_1_a.png?task_id=7&w=') +}) + +it('renders nothing when the task has no samples', async () => { + vi.spyOn(api, 'listTaskSamples').mockResolvedValue({ items: [], total: 0 }) + const { container } = render() + await waitFor(() => expect(container).toBeEmptyDOMElement()) +}) + +it('stays quiet when the listing fails', async () => { + vi.spyOn(api, 'listTaskSamples').mockRejectedValue(new Error('boom')) + const { container } = render() + await waitFor(() => expect(container).toBeEmptyDOMElement()) +}) + +it('opens the lightbox on a thumbnail and steps through the feed', async () => { + vi.spyOn(api, 'listTaskSamples').mockResolvedValue({ + items: [sample(), sample({ filename: 'epoch_2_a.png', epoch: 2, mtime: 2 })], + total: 2, + }) + render() + + const strip = await screen.findByTestId('sample-strip-7') + await waitFor(() => expect(strip.querySelectorAll('button')).toHaveLength(2)) + fireEvent.click(strip.querySelectorAll('button')[0]) + + // 灯箱:计数器 + 原图(无 w= 参数) + expect(await screen.findByText('1 / 2')).toBeInTheDocument() + const full = screen.getByAltText(/epoch_1_a\.png/) + expect(full.getAttribute('src')).toBe('/samples/epoch_1_a.png?task_id=7') + + fireEvent.click(screen.getByLabelText('Next')) + expect(await screen.findByText('2 / 2')).toBeInTheDocument() + + fireEvent.click(screen.getByLabelText('Close')) + await waitFor(() => expect(screen.queryByText('2 / 2')).not.toBeInTheDocument()) +}) diff --git a/studio/web/src/components/TaskSampleStrip.tsx b/studio/web/src/components/TaskSampleStrip.tsx new file mode 100644 index 0000000..e43f0fd --- /dev/null +++ b/studio/web/src/components/TaskSampleStrip.tsx @@ -0,0 +1,204 @@ +/** + * TaskSampleStrip —— 队列列表行内联的采样图带。 + * + * 队列页每行(有 monitor_state_path 的训练任务)底下铺一条横向缩略图带, + * 不用点进任务详情就能翻这次训练出的图;点任一张开灯箱(ImagePreviewModal, + * ← / → 在本任务的采样序列里前后切)。 + * + * 取数走 `GET /api/queue/{id}/samples`(扫盘,已结束的任务也有图),而不是 + * monitor state —— 后者只有 running task 推、且 cap 50。 + * + * 两个「别把队列页拖垮」的约束: + * - **懒加载**:IntersectionObserver,行滚进视口才发请求;队列几百行时只有 + * 屏幕上那几行真的打后端。 + * - **live 防抖**:running task 订阅 monitor_progress,只在 delta 里真有 + * appended_samples 时才安排一次 1.5s 后的重拉(训练每几十步出一张图, + * 没必要跟着每条 delta 抖)。 + */ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { api, type TaskSample } from '../api/client' +import { useEventStream } from '../lib/useEventStream' +import ImagePreviewModal from './ImagePreviewModal' + +const THUMB_PX = 72 +/** 缩略图请求宽度:2× 显示尺寸,HiDPI 屏不糊。 */ +const THUMB_REQ = 160 + +function marks(s: TaskSample): string { + const parts: string[] = [] + if (s.epoch != null) parts.push(`ep ${s.epoch.toLocaleString()}`) + if (s.step != null) parts.push(`step ${s.step.toLocaleString()}`) + return parts.join(' · ') +} + +function shortMarks(s: TaskSample): string { + const parts: string[] = [] + if (s.epoch != null) parts.push(`ep${s.epoch}`) + if (s.step != null) parts.push(String(s.step)) + return parts.join('·') +} + +export default function TaskSampleStrip({ taskId, live = false }: { + taskId: number + /** running task → 订阅 monitor_progress,出新图自动追加。 */ + live?: boolean +}) { + const { t } = useTranslation() + const [items, setItems] = useState(null) + const [zoomIdx, setZoomIdx] = useState(null) + const hostRef = useRef(null) + const scrollRef = useRef(null) + const [inView, setInView] = useState(false) + const refetchTimer = useRef(null) + // 用户自己往回滚看早期图时别把他拽回末尾;只在「本来就贴着右端」时跟随。 + const followRef = useRef(true) + + const load = useCallback(async () => { + try { + const r = await api.listTaskSamples(taskId) + setItems(r.items) + } catch { + setItems([]) // 网络错 / task 没了 → 当作没图,队列页不弹错 + } + }, [taskId]) + + // 懒加载:滚进视口(提前 200px)才拉一次。 + useEffect(() => { + const el = hostRef.current + if (!el) return + if (typeof IntersectionObserver === 'undefined') { setInView(true); return } + const io = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) { + setInView(true) + io.disconnect() + } + }, + { rootMargin: '200px' }, + ) + io.observe(el) + return () => io.disconnect() + }, []) + + useEffect(() => { + if (!inView) return + void load() + }, [inView, load]) + + useEventStream((evt) => { + if (!live || !inView) return + if (evt.type !== 'monitor_progress') return + if (String(evt.task_id) !== String(taskId)) return + const delta = evt.delta as { appended_samples?: unknown[] } | undefined + if (!delta?.appended_samples?.length) return + if (refetchTimer.current) return + refetchTimer.current = window.setTimeout(() => { + refetchTimer.current = null + void load() + }, 1500) + }) + + useEffect(() => () => { + if (refetchTimer.current) window.clearTimeout(refetchTimer.current) + }, []) + + // 新图追加后跟到末尾(仅在用户没往回滚时)。 + const count = items?.length ?? 0 + useEffect(() => { + const el = scrollRef.current + if (!el || !followRef.current) return + el.scrollLeft = el.scrollWidth + }, [count]) + + if (items !== null && items.length === 0) return null + + return ( +
e.stopPropagation()} + > + {items === null ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ ) : ( + <> +
+ + {t('queue.samples')} + + + {t('queue.samplesCount', { n: items.length })} + +
+
{ + const el = e.currentTarget + followRef.current = + el.scrollWidth - el.scrollLeft - el.clientWidth < 24 + }} + className="flex gap-1.5 overflow-x-auto pb-1" + style={{ scrollbarWidth: 'thin' }} + data-testid={`sample-strip-${taskId}`} + > + {items.map((s, i) => { + const caption = shortMarks(s) + return ( + + ) + })} +
+ + )} + + {zoomIdx !== null && items && items[zoomIdx] && ( + 0} + hasNext={zoomIdx < items.length - 1} + onClose={() => setZoomIdx(null)} + onPrev={() => setZoomIdx((i) => Math.max(0, (i ?? 0) - 1))} + onNext={() => setZoomIdx((i) => Math.min(items.length - 1, (i ?? 0) + 1))} + /> + )} +
+ ) +} diff --git a/studio/web/src/i18n/locales/en.json b/studio/web/src/i18n/locales/en.json index 373cedc..f5597c9 100644 --- a/studio/web/src/i18n/locales/en.json +++ b/studio/web/src/i18n/locales/en.json @@ -887,7 +887,23 @@ }, "resumeFailedMissing": "Recovery-point file is missing — cannot resume. Start a fresh task from another .pt via ResumeFieldPicker.", "resumeFailed": "Resume failed: {{reason}}", - "pauseFailed": "Pause failed: {{reason}}" + "pauseFailed": "Pause failed: {{reason}}", + "note": "Note", + "noteAdd": "Add note", + "noteEdit": "Edit note", + "noteRemove": "Remove note", + "notePrompt": "Note for task #{{id}}", + "notePlaceholder": "e.g. alpha 16, dataset v3 — what was different this run", + "noteTooLong": "Keep it under {{max}} characters", + "noteSaved": "Note saved", + "noteCleared": "Note removed", + "noteFailed": "Could not save the note: {{reason}}", + "noteEmpty": "No note yet", + "noteHint": "Right-click a task in the queue to add a note", + "samples": "Samples", + "samplesCount": "{{n}} imgs", + "samplesShow": "Show samples", + "samplesHide": "Hide samples" }, "queueDetail": { "invalidId": "Invalid task ID", diff --git a/studio/web/src/i18n/locales/ru.json b/studio/web/src/i18n/locales/ru.json index 1fd82a7..bff467c 100644 --- a/studio/web/src/i18n/locales/ru.json +++ b/studio/web/src/i18n/locales/ru.json @@ -1049,5 +1049,23 @@ "dopNeedsTrigger": "DOP включён, но триггер-слово не сохранено — тренировка упадёт на старте. Сохраните триггер или выключите DOP.", "saved": "Триггер-слово сохранено", "saveFailed": "Не удалось сохранить триггер-слово: {{error}}" + }, + "queue": { + "note": "Заметка", + "noteAdd": "Добавить заметку", + "noteEdit": "Изменить заметку", + "noteRemove": "Удалить заметку", + "notePrompt": "Заметка к задаче #{{id}}", + "notePlaceholder": "например: alpha 16, датасет v3 — что поменяли в этот раз", + "noteTooLong": "Не длиннее {{max}} символов", + "noteSaved": "Заметка сохранена", + "noteCleared": "Заметка удалена", + "noteFailed": "Не удалось сохранить заметку: {{reason}}", + "noteEmpty": "Заметки пока нет", + "noteHint": "Правый клик по задаче в очереди — чтобы добавить заметку", + "samples": "Семплы", + "samplesCount": "{{n}} шт.", + "samplesShow": "Показать семплы", + "samplesHide": "Скрыть семплы" } } diff --git a/studio/web/src/i18n/locales/zh.json b/studio/web/src/i18n/locales/zh.json index 79465db..b114d32 100644 --- a/studio/web/src/i18n/locales/zh.json +++ b/studio/web/src/i18n/locales/zh.json @@ -768,7 +768,23 @@ }, "resumeFailedMissing": "恢复点文件已丢失,无法恢复。请通过 ResumeFieldPicker 新建任务从其他 .pt 起。", "resumeFailed": "恢复失败:{{reason}}", - "pauseFailed": "暂停失败:{{reason}}" + "pauseFailed": "暂停失败:{{reason}}", + "note": "备注", + "noteAdd": "添加备注", + "noteEdit": "编辑备注", + "noteRemove": "删除备注", + "notePrompt": "任务 #{{id}} 的备注", + "notePlaceholder": "例:alpha 16、数据集 v3 —— 这次改了什么", + "noteTooLong": "不要超过 {{max}} 个字符", + "noteSaved": "备注已保存", + "noteCleared": "备注已删除", + "noteFailed": "备注保存失败:{{reason}}", + "noteEmpty": "还没有备注", + "noteHint": "在队列页右键任务即可添加备注", + "samples": "采样图", + "samplesCount": "{{n}} 张", + "samplesShow": "显示采样图", + "samplesHide": "隐藏采样图" }, "queueDetail": { "invalidId": "无效任务 ID", diff --git a/studio/web/src/pages/Queue.test.tsx b/studio/web/src/pages/Queue.test.tsx new file mode 100644 index 0000000..eff2ac1 --- /dev/null +++ b/studio/web/src/pages/Queue.test.tsx @@ -0,0 +1,118 @@ +/** 队列页:行内联采样条 + 右键备注菜单(_v20 tasks.note)。 */ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +import { api, type Task } from '../api/client' +import { DialogProvider } from '../components/Dialog' +import { ToastProvider } from '../components/Toast' +import QueuePage from './Queue' + +class FakeEventSource { + static readonly OPEN = 1 + onopen: (() => void) | null = null + onmessage: ((e: { data: string }) => void) | null = null + onerror: (() => void) | null = null + readyState = FakeEventSource.OPEN + close(): void { this.readyState = 2 } +} + +function makeTask(over: Partial = {}): Task { + return { + id: 1, name: 'lora run', config_name: 'train_lora', task_type: 'train', + status: 'done', priority: 0, + created_at: 900, started_at: 1000, finished_at: 1200, + pid: null, exit_code: 0, output_dir: null, error_msg: null, + monitor_state_path: 'C:/x/monitor_state.json', + note: null, + ...over, + } +} + +beforeEach(() => { + vi.stubGlobal('EventSource', FakeEventSource) + vi.spyOn(api, 'getQueueHold').mockResolvedValue({ held: false, pending_waiting: 0 } as never) + vi.spyOn(api, 'listTaskSamples').mockResolvedValue({ items: [], total: 0 }) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +function renderQueue() { + return render( + + + + + + + , + ) +} + +it('shows an existing note on the row', async () => { + vi.spyOn(api, 'listQueue').mockResolvedValue([makeTask({ note: 'alpha 16' })]) + renderQueue() + const note = await screen.findByTestId('task-note-1') + expect(note).toHaveTextContent('alpha 16') +}) + +it('right-click opens the menu and saves a note', async () => { + vi.spyOn(api, 'listQueue').mockResolvedValue([makeTask()]) + const setNote = vi.spyOn(api, 'setTaskNote').mockResolvedValue( + makeTask({ note: 'dataset v3' }), + ) + renderQueue() + + fireEvent.contextMenu(await screen.findByText('lora run')) + const menu = await screen.findByTestId('queue-context-menu') + // 没备注时是「添加备注」,没有「删除备注」项 + expect(menu).toHaveTextContent('添加备注') + expect(menu).not.toHaveTextContent('删除备注') + + fireEvent.click(screen.getByText('添加备注')) + const input = await screen.findByRole('textbox') + fireEvent.change(input, { target: { value: 'dataset v3' } }) + fireEvent.click(screen.getByText('保存')) + + await waitFor(() => expect(setNote).toHaveBeenCalledWith(1, 'dataset v3')) + expect(await screen.findByTestId('task-note-1')).toHaveTextContent('dataset v3') +}) + +it('removes a note straight from the menu', async () => { + vi.spyOn(api, 'listQueue').mockResolvedValue([makeTask({ note: 'old' })]) + const setNote = vi.spyOn(api, 'setTaskNote').mockResolvedValue(makeTask({ note: null })) + renderQueue() + + fireEvent.contextMenu(await screen.findByText('lora run')) + fireEvent.click(await screen.findByText('删除备注')) + + await waitFor(() => expect(setNote).toHaveBeenCalledWith(1, '')) + await waitFor(() => expect(screen.queryByTestId('task-note-1')).not.toBeInTheDocument()) +}) + +it('feeds the row samples and hides them on request', async () => { + vi.spyOn(api, 'listQueue').mockResolvedValue([makeTask()]) + vi.spyOn(api, 'listTaskSamples').mockResolvedValue({ + items: [{ filename: 'epoch_1.png', mtime: 1, size: 1, epoch: 1, step: null }], + total: 1, + }) + renderQueue() + + expect(await screen.findByTestId('sample-strip-1')).toBeInTheDocument() + + fireEvent.contextMenu(screen.getByText('lora run')) + fireEvent.click(await screen.findByText('隐藏采样图')) + await waitFor(() => expect(screen.queryByTestId('sample-strip-1')).not.toBeInTheDocument()) +}) + +it('does not fetch samples for tasks without a monitor state', async () => { + const list = vi.spyOn(api, 'listTaskSamples') + vi.spyOn(api, 'listQueue').mockResolvedValue([makeTask({ monitor_state_path: null })]) + renderQueue() + + await screen.findByText('lora run') + expect(list).not.toHaveBeenCalled() +}) diff --git a/studio/web/src/pages/Queue.tsx b/studio/web/src/pages/Queue.tsx index 8e65b3c..65b47f9 100644 --- a/studio/web/src/pages/Queue.tsx +++ b/studio/web/src/pages/Queue.tsx @@ -6,11 +6,15 @@ import { HoldQueueModal, type HoldDecision } from '../components/HoldQueueModal' import { PauseConfirmModal } from '../components/PauseConfirmModal' import { PauseProgressModal } from '../components/PauseProgressModal' import StepShell from '../components/StepShell' +import TaskSampleStrip from '../components/TaskSampleStrip' import { useDialog } from '../components/Dialog' import { useToast } from '../components/Toast' import { useEventStream } from '../lib/useEventStream' import { useMonitorProgress } from '../lib/useMonitorProgress' +/** 备注输入上限 —— 与后端 _MAX_NOTE_LEN 对齐(超了后端截断,这里先拦住)。 */ +const MAX_NOTE_LEN = 500 + type TaskKind = 'train' | 'tag' | 'reg' | 'download' | 'curate' | 'unknown' @@ -67,8 +71,12 @@ export default function QueuePage() { const [exporting, setExporting] = useState(false) const reloadTimer = useRef(null) const { toast } = useToast() - const { confirm } = useDialog() + const { confirm, prompt } = useDialog() const navigate = useNavigate() + // 右键菜单(备注 / 折叠采样条):null = 关闭;否则记录锚点坐标 + 目标 task。 + const [menu, setMenu] = useState<{ x: number; y: number; task: Task } | null>(null) + // 采样条默认展开;用户按任务折叠,记在这里(只在本次会话内存活)。 + const [stripsHidden, setStripsHidden] = useState>(new Set()) const STATUS_LABEL: Record = { scheduled: t('status.queued'), @@ -234,6 +242,41 @@ export default function QueuePage() { } } + // ── 备注(_v20 tasks.note)──────────────────────────────────────────────── + // 右键任一行 → 写一句话("这次 alpha=16"、"数据集换 v3")。备注跟着 task 走, + // 队列行上直接显示,任务详情页 overview 也能看 / 改。 + const editNote = async (task: Task) => { + const next = await prompt(t('queue.notePrompt', { id: task.id }), { + title: task.note ? t('queue.noteEdit') : t('queue.noteAdd'), + defaultValue: task.note ?? '', + placeholder: t('queue.notePlaceholder'), + validate: (v) => + v.length > MAX_NOTE_LEN ? t('queue.noteTooLong', { max: MAX_NOTE_LEN }) : null, + okText: t('common.save'), + }) + if (next === null) return + await saveNote(task, next) + } + + const saveNote = async (task: Task, note: string) => { + try { + const updated = await api.setTaskNote(task.id, note) + setTasks((ts) => ts.map((x) => (x.id === task.id ? { ...x, note: updated.note } : x))) + toast(updated.note ? t('queue.noteSaved') : t('queue.noteCleared'), 'success') + } catch (e) { + toast(t('queue.noteFailed', { reason: String(e) }), 'error') + } + } + + const toggleStrip = (taskId: number) => { + setStripsHidden((prev) => { + const next = new Set(prev) + if (next.has(taskId)) next.delete(taskId) + else next.add(taskId) + return next + }) + } + const cancelPaused = async (task: Task) => { const ok = await confirm( `${t('queue.cancelPaused')} #${task.id}?${t('queue.cancelPausedHint')}`, @@ -416,14 +459,30 @@ export default function QueuePage() { const eta = estimateEta(task) const tone = STATUS_TONE[task.status] + // 训练类任务才有采样图目录;其余(download/tag/…)不铺采样条, + // 也就不会为它们发那条清单请求。 + const canShowSamples = !!task.monitor_state_path && !stripsHidden.has(task.id) + return ( - + + {/* 备注条 —— 右键写的一句话,队列里一眼可见(详情页 overview 同源)。 */} + {task.note && ( +
+ + {t('queue.note')} + + + {task.note} + + +
+ )} + + {/* 内联采样条 —— 不用点进详情就能翻这次训练出的图,点开灯箱。 */} + {canShowSamples && ( + + )} +
) })} )} + {/* 右键菜单 —— 备注 + 采样条折叠。点任意处 / ESC 关(见组件内部)。 */} + {menu && ( + setMenu(null)} + items={[ + { + label: menu.task.note ? t('queue.noteEdit') : t('queue.noteAdd'), + onSelect: () => void editNote(menu.task), + }, + ...(menu.task.note ? [{ + label: t('queue.noteRemove'), + tone: 'err' as const, + onSelect: () => void saveNote(menu.task, ''), + }] : []), + ...(menu.task.monitor_state_path ? [{ + label: stripsHidden.has(menu.task.id) + ? t('queue.samplesShow') + : t('queue.samplesHide'), + onSelect: () => toggleStrip(menu.task.id), + }] : []), + { + label: t('queue.taskDetailTooltip'), + onSelect: () => navigate(`/queue/${menu.task.id}`), + }, + ]} + /> + )} + {/* ADR Addendum 1 §UI:暂停 confirm modal — 告知用户语义后才调 api。 */} {pauseConfirmTaskId !== null && ( ) } + +// ── TaskContextMenu ───────────────────────────────────────────────────────── +// 队列行右键弹出的小菜单。定位用 fixed + 视口边界夹紧(靠右/靠下的行不出屏)。 +// 任何一次点击 / 滚动 / ESC 都关闭 —— 菜单本身的点击由条目 onSelect 先跑完。 + +interface MenuItem { + label: string + onSelect: () => void + tone?: 'err' +} + +function TaskContextMenu({ x, y, items, onClose }: { + x: number + y: number + items: MenuItem[] + onClose: () => void +}) { + const ref = useRef(null) + const [pos, setPos] = useState({ x, y }) + + // 挂载后按真实尺寸夹回视口内(菜单高度随条目数变,先渲染再量)。 + useEffect(() => { + const el = ref.current + if (!el) return + const r = el.getBoundingClientRect() + setPos({ + x: Math.min(x, window.innerWidth - r.width - 8), + y: Math.min(y, window.innerHeight - r.height - 8), + }) + }, [x, y]) + + useEffect(() => { + const close = () => onClose() + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } + // capture 阶段 + 下一帧注册:避免打开菜单的那次 contextmenu/click 立刻关掉它。 + const tid = window.setTimeout(() => { + window.addEventListener('click', close) + window.addEventListener('contextmenu', close) + window.addEventListener('scroll', close, true) + }, 0) + window.addEventListener('keydown', onKey) + return () => { + window.clearTimeout(tid) + window.removeEventListener('click', close) + window.removeEventListener('contextmenu', close) + window.removeEventListener('scroll', close, true) + window.removeEventListener('keydown', onKey) + } + }, [onClose]) + + return ( +
e.preventDefault()} + > + {items.map((it) => ( + + ))} +
+ ) +} diff --git a/studio/web/src/pages/QueueDetail.tsx b/studio/web/src/pages/QueueDetail.tsx index c4bd341..29d7309 100644 --- a/studio/web/src/pages/QueueDetail.tsx +++ b/studio/web/src/pages/QueueDetail.tsx @@ -273,6 +273,9 @@ export default function QueueDetailPage() { )} + {/* 备注(_v20 tasks.note)—— 队列页右键写的那句话,这里完整显示 + 可改。 */} + {task && } + {/* Stat cards for running tasks */} {task && task.status === 'running' && (
@@ -345,6 +348,104 @@ export default function QueueDetailPage() { ) } +// ── TaskNoteCard ──────────────────────────────────────────────────────────── +// 队列页右键给任务贴的备注(_v20 tasks.note)在训练页顶部完整显示,就地可改。 +// 这里给 textarea(多行)而队列页给单行 prompt —— 同一个字段,详情页是「写长 +// 一点」的地方。 + +function TaskNoteCard({ task, onSaved }: { + task: Task + onSaved: (t: Task) => void +}) { + const { t } = useTranslation() + const { toast } = useToast() + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(task.note ?? '') + const [saving, setSaving] = useState(false) + + const startEdit = () => { + setDraft(task.note ?? '') + setEditing(true) + } + + const save = async () => { + setSaving(true) + try { + const updated = await api.setTaskNote(task.id, draft) + onSaved(updated) + toast(updated.note ? t('queue.noteSaved') : t('queue.noteCleared'), 'success') + setEditing(false) + } catch (e) { + toast(t('queue.noteFailed', { reason: String(e) }), 'error') + } finally { + setSaving(false) + } + } + + if (editing) { + return ( +
+ + {t('queue.note')} + +