File size: 19,576 Bytes
bb59211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
/** 「新建训练」页签 —— 五段式向导。
 *
 *   ① 训练数据集(审核页导出后留下的成品,见 state/datasets/)
 *   ② 选择模型(ACT)+ 训练框架(LeRobot,只读)
 *   ③ 训练位置(私有算力 + 勾选参与训练的 GPU)
 *   ④ 训练参数(每项带一句说明)
 *   ⑤ 从已有训练继续(可选)
 *
 * **本期只做前端 —— 所以这一页是纯表单。**
 *
 * 这里曾经有一个禁用的「创建训练任务」按钮、外加一条生成好的 `lerobot-train`
 * 命令行(带复制 / 下载 .sh)。已经删掉了:后端执行接上之前,那两样要么是假的
 * 按钮,要么是绕路把命令当交付物。等真接上后端时,直接加一个**能点的**按钮,
 * 把下面这些字段 POST 出去即可 —— 表单本身就已经是接口形状了。
 *
 * ★ 相应地,现在这一页的选择(数据集 / GPU / 参数 / 续训)**都不驱动任何输出**。
 *   它们是为将来后端准备的状态,不是装饰;但也别指望现在改一下会有什么反应。
 *
 * 只有 ACT —— 其它策略(π0 / SmolVLA / diffusion…)都是 LeRobot 内置、跑得通,
 * 但没在这套硬件上实测过,所以不摆一排点不动的卡片。
 */

import { el, clear } from '../shared/dom.js';
import { bytes, dateTime, thousands } from '../shared/format.js';
import { tr } from '../shared/text.js';
import { listDatasets, listGpus, listReadyProjects, listRuns } from './api.js';

/** 默认超参 —— 取自**实测那一次**的真实 train_config.json
 *  (checkpoints/020000),不是参考实现的示例值。
 *
 *  参考实现里 batch_size=2、steps=10000 是给它那个 8GB 的 pi0 用的;我们这次
 *  198MB 的 ACT 在这台机器上 batch_size=16 / steps=20000 是跑通过的。首次训练
 *  用跑通过的更稳。 */
const DEFAULTS = {
  job_name: 'act',
  batch_size: 16,
  steps: 20000,
  seed: 1000,
  num_workers: 4,
  eval_freq: 1000,
  log_freq: 100,
  save_checkpoint: true,
  save_freq: 5000,
};

/** 参数说明 —— 直接对应参考实现每项右侧那句话,首次训练的人看得懂。 */
const PARAM_HELP = {
  job_name: ['train_help_job_name', 'Name of this run. It becomes the checkpoint folder name, so keep it short and unique.'],
  batch_size: ['train_help_batch', 'Larger batches train more stably but need more VRAM; smaller ones are faster but less stable. Suggested 1-32.'],
  steps: ['train_help_steps', 'More steps usually improve the policy but risk overfitting. Start at 10000 and adjust from validation.'],
  seed: ['train_help_seed', 'A fixed seed reproduces the same run, which makes debugging and comparison possible. 1000 or 42.'],
  num_workers: ['train_help_workers', 'More workers load data faster but use more memory. About 1/2 to 1x the CPU core count.'],
  eval_freq: ['train_help_eval_freq', 'How often to run an evaluation, in steps. About 10% of total steps.'],
  log_freq: ['train_help_log_freq', 'How often to refresh progress, in steps. 10-100 works well.'],
  save_checkpoint: ['train_help_save_ckpt', 'Save checkpoints while training so a run can be resumed or deployed.'],
  save_freq: ['train_help_save_freq', 'How often to save a checkpoint, in steps. About 30% of total steps.'],
};


const INPUT_CLASS = 'bg-gray-800 text-xs rounded px-2 py-1 border border-gray-700 '
  + 'focus:border-blue-500 focus:outline-none font-mono';
const BTN = 'bg-gray-800 hover:bg-gray-700 text-gray-300 text-xs px-2.5 py-1 '
  + 'rounded border border-gray-700';

const LIVE = 'bg-green-900/50 text-green-300';
const PENDING = 'bg-amber-900/50 text-amber-300';

function section(titleKey, fallback, badge, children) {
  return el('div', { class: 'bg-gray-900 rounded-lg border border-gray-800 p-4 mb-4' }, [
    el('div', { class: 'flex items-center gap-3 mb-3' }, [
      el('div', { class: 'text-sm font-semibold text-gray-200', text: tr(titleKey, fallback) }),
      badge ? el('span', { class: `text-[10px] px-1.5 py-0.5 rounded ${badge.cls}`, text: badge.text }) : null,
    ]),
    ...children,
  ]);
}

function hint(key, fallback) {
  return el('div', { class: 'text-xs text-gray-600 mt-3', text: tr(key, fallback) });
}

/** 一行「标签 + 控件」,失败/加载中都用它占位。 */
function labeled(label, control, labelClass = 'text-gray-500 shrink-0 w-32') {
  return el('div', { class: 'flex items-center gap-3 text-xs' }, [
    el('span', { class: labelClass, text: label }),
    control,
  ]);
}

function loadingRow(text) {
  return el('div', { class: 'text-xs text-gray-600 py-3', text });
}

function errorRow(text) {
  return el('div', { class: 'text-xs text-red-400 py-3', text });
}

export function renderNewRun(container) {
  clear(container);

  const state = { gpus: new Set(), dataset: null, resume: null };

  // ── ① 训练位置 ──────────────────────────────────────
  const gpuHost = el('div', {});
  const locationCard = el('div', {
    class: 'inline-block border-2 border-blue-500 bg-blue-950/30 rounded-lg px-4 py-3 min-w-[220px]',
  }, [
    el('div', { class: 'text-sm font-semibold text-gray-100', text: tr('train_loc_local', 'Local GPU server') }),
    el('div', { class: 'text-xs text-gray-400 mt-1', text: tr('train_loc_local_desc', 'GPUs on this machine') }),
  ]);

  const sectionLocation = section('train_sec_location', '1. Training Location', null, [
    locationCard,
    el('div', { class: 'mt-4' }, [gpuHost]),
  ]);

  function renderGpus(payload) {
    clear(gpuHost);
    if (!payload.available) {
      // 驱动没起来时**必须区别显示** —— 渲染成"利用率 0%"会让人以为显卡闲着。
      gpuHost.appendChild(el('div', {
        class: 'text-xs text-amber-300 bg-amber-950/40 border border-amber-900 rounded px-3 py-2',
      }, [
        el('div', { text: tr('train_gpu_unavailable', 'Cannot read the GPUs') }),
        el('div', { class: 'text-amber-500/80 mt-1 font-mono break-all', text: payload.error }),
        el('div', { class: 'text-amber-500/80 mt-1', text: tr('train_gpu_unavailable_hint', 'Fix the driver before training — training cannot start without it.') }),
      ]));
      return;
    }
    if (!payload.gpus.length) {
      gpuHost.appendChild(errorRow(tr('train_gpu_none', 'nvidia-smi reported no GPUs.')));
      return;
    }

    gpuHost.appendChild(el('div', { class: 'flex gap-3 flex-wrap' },
      payload.gpus.map((gpu) => {
        const usedRatio = gpu.memory_total_mb
          ? Math.round((gpu.memory_used_mb / gpu.memory_total_mb) * 100) : 0;
        const busy = gpu.utilization > 5 && gpu.index !== 0;

        const checkbox = el('input', {
          type: 'checkbox', class: 'accent-blue-500',
          checked: state.gpus.has(gpu.index),
        });
        checkbox.addEventListener('change', () => {
          if (checkbox.checked) state.gpus.add(gpu.index);
          else state.gpus.delete(gpu.index);
        });

        return el('div', { class: 'bg-gray-900 border border-gray-800 rounded-lg p-3 w-64' }, [
          el('div', { class: 'flex items-center gap-2' }, [
            el('span', { class: 'text-sm font-semibold text-gray-100', text: `GPU ${gpu.index}` }),
            el('span', {
              class: `ml-auto text-[10px] px-1.5 py-0.5 rounded ${busy ? 'bg-amber-900/50 text-amber-300' : 'bg-green-900/50 text-green-300'}`,
              text: busy ? tr('train_gpu_busy', 'busy') : tr('train_gpu_idle', 'idle'),
            }),
          ]),
          el('div', { class: 'text-xs text-gray-400 mt-1', text: gpu.name }),
          el('div', { class: 'mt-2 text-xs text-gray-500' }, [
            el('div', { text: `${tr('train_gpu_mem', 'VRAM')} ${gpu.memory_used_mb} / ${gpu.memory_total_mb} MB (${usedRatio}%)` }),
            el('div', { class: 'h-1.5 bg-gray-800 rounded mt-1' }, [
              el('div', { class: 'h-1.5 bg-blue-500 rounded', style: `width:${usedRatio}%` }),
            ]),
            el('div', { class: 'mt-1', text: `${tr('train_gpu_util', 'Utilization')} ${gpu.utilization}%` }),
            el('div', { text: `${tr('train_gpu_temp', 'Temp')} ${gpu.temperature}°C` }),
          ]),
          el('label', { class: 'flex items-center gap-2 mt-2 text-xs text-gray-400 cursor-pointer' }, [
            checkbox,
            el('span', { text: tr('train_gpu_include', 'Use for training') }),
          ]),
        ]);
      })));
  }

  gpuHost.appendChild(loadingRow(tr('loading', 'Loading...')));
  listGpus()
    .then(renderGpus)
    .catch((err) => { clear(gpuHost); gpuHost.appendChild(errorRow(`${tr('train_load_failed', 'Failed to load')}: ${err.message}`)); });

  // ── ② 选择模型 + 训练框架 ───────────────────────────
  const modelCard = el('div', {
    class: 'inline-block border-2 border-blue-500 bg-blue-950/30 rounded-lg px-4 py-3 min-w-[240px]',
  }, [
    el('div', { class: 'flex items-center gap-2' }, [
      el('span', { class: 'text-sm font-semibold text-gray-100', text: 'ACT' }),
      el('span', { class: 'text-[10px] px-1.5 py-0.5 rounded bg-blue-900/60 text-blue-300', text: tr('train_new_model_tested', 'tested') }),
    ]),
    el('div', { class: 'text-xs text-gray-400 mt-1.5', text: tr('train_new_model_act_desc', 'Transformer action-chunking imitation learning policy') }),
  ]);

  const sectionModel = section('train_sec_model', '2. Model', null, [
    el('div', { class: 'flex items-start gap-6 flex-wrap' }, [
      el('div', {}, [modelCard]),
      el('div', {}, [
        el('div', { class: 'text-xs text-gray-500 mb-1.5', text: tr('train_framework', 'Training framework') }),
        // 只读:ACT 在 LeRobot 里只有一种实现,没有可选项。做成下拉框是假的。
        el('div', {
          class: 'inline-block bg-gray-800 border border-gray-700 rounded px-3 py-1.5 text-xs text-gray-200 font-mono',
          text: 'LeRobot',
        }),
        el('div', { class: 'text-xs text-gray-600 mt-1.5', text: tr('train_framework_hint', 'ACT ships as a single LeRobot implementation, so there is nothing to choose here.') }),
      ]),
    ]),
  ]);

  // ── ③ 训练参数 ──────────────────────────────────────
  const paramInputs = {};
  const paramRows = Object.keys(DEFAULTS).map((name) => {
    const [helpKey, helpFallback] = PARAM_HELP[name] || ['', ''];
    let control;
    if (name === 'save_checkpoint') {
      control = el('select', { class: INPUT_CLASS + ' w-28' }, [
        el('option', { value: 'true', text: 'true' }),
        el('option', { value: 'false', text: 'false' }),
      ]);
    } else {
      control = el('input', { class: INPUT_CLASS + ' w-28', value: String(DEFAULTS[name]) });
    }
    paramInputs[name] = control;

    return el('div', { class: 'flex items-start gap-4 text-xs py-1' }, [
      el('span', { class: 'text-gray-400 shrink-0 w-36 font-mono pt-1.5', text: name }),
      el('div', { class: 'shrink-0' }, [control]),
      el('span', { class: 'text-gray-600 pt-1.5 flex items-start gap-1.5' }, [
        el('iconify-icon', {
          icon: 'ant-design:info-circle-outlined',
          class: 'mt-0.5 shrink-0',
        }),
        el('span', { text: tr(helpKey, helpFallback) }),
      ]),
    ]);
  });

  const sectionParams = section('train_sec_params', '3. Training Parameters',
    { cls: PENDING, text: tr('train_new_pending', 'not wired') }, [
      ...paramRows,
      el('button', {
        class: 'mt-3 ' + BTN,
        text: tr('train_new_reset', 'Restore measured defaults'),
        on: {
          click: () => {
            for (const [name, control] of Object.entries(paramInputs)) control.value = String(DEFAULTS[name]);
          },
        },
      }),
    ]);

  // ── ④ 训练数据集 ────────────────────────────────────
  //
  // 来源是**在视频审核页导出后留下来的成品**(`state/datasets/<名字>`),不是
  // 原始项目。原始项目里混着未审核的集,而且留出集(UMIGripper_Action_AI 的
  // 52–73)就在里面 —— 按项目选只能报警、拦不住。改以导出产物为准之后,两个
  // 坑都在导出那一步被排掉了。
  //
  // ★ 正因如此,这里**不再有留出集防线**:导出后的数据集集号会被重排成
  //   0..N-1,已经认不出哪一集原本是 52–73。控制点转移到了导出那一步 ——
  //   导出时别把留出集勾进去。要加硬校验的话,位置在审核/导出页而不是这里。
  const datasetHost = el('div', {});
  const readyHost = el('div', {});

  const sectionDataset = section('train_sec_dataset', '4. Training Dataset',
    { cls: LIVE, text: tr('train_new_live', 'live') }, [
      datasetHost,
      readyHost,
    ]);

  /** 「到点提醒」—— 项目已通过集数达到它自己设的采集上限时高亮出来。
   *
   *  **只提醒,不自动导出。** 导出一次约 30 秒/集(UI 自己的文案),1000 集要跑
   *  8 小时以上;而且构建好的数据集会被"固定"下来 —— 已经拿去训练的那份不能
   *  悄悄变。让人点一下,换到的是"我知道现在在导什么、用哪份数据"。
   *
   *  没到点时也给一行「最接近的」,否则这块永远是空的,看不出进展。 */
  function renderReady(payload) {
    clear(readyHost);
    const ready = (payload.projects || []).filter((p) => p.ready);

    if (ready.length) {
      readyHost.appendChild(el('div', {
        class: 'mt-3 text-xs bg-green-950/40 border border-green-900 rounded px-3 py-2',
      }, [
        el('div', {
          class: 'text-green-300 font-medium mb-2',
          text: tr('train_ready_title', 'Reached its collection limit — ready to export'),
        }),
        ...ready.map((p) => el('div', { class: 'flex items-center gap-3 py-0.5' }, [
          el('span', { class: 'text-gray-200 font-mono flex-1 truncate', text: p.name }),
          el('span', { class: 'text-gray-400 shrink-0', text: `${p.approved} / ${p.target}` }),
          el('a', {
            href: '/review?status=approved',
            class: 'text-blue-400 hover:underline shrink-0',
            text: tr('train_ready_go', 'Go export'),
          }),
        ])),
      ]));
      return;
    }

    // 没到点就**什么都不显示** —— 曾经在这里给一行「离采集上限最近的 X: n/N」,
    // 但它每次都在,而绝大多数时候又离得很远(实测 3/100、52/10000),读起来像
    // 一句永远不变的噪音。到点了自然会冒出来,没到点就安静待着。
  }

  function datasetEmptyState() {
    return el('div', { class: 'text-center py-6' }, [
      el('div', { class: 'text-xs text-gray-500 mb-3', text: tr('train_dataset_none', 'No exported dataset yet.') }),
      el('a', {
        href: '/review?status=approved',
        class: 'inline-block bg-blue-600 hover:bg-blue-500 text-white text-xs px-3 py-1.5 rounded',
        text: tr('train_dataset_goto_export', 'Go export approved episodes'),
      }),
    ]);
  }

  function renderDatasets(payload) {
    clear(datasetHost);
    if (!payload.datasets.length) {
      datasetHost.appendChild(datasetEmptyState());
      return;
    }
    datasetHost.appendChild(el('div', { class: 'border border-gray-800 rounded divide-y divide-gray-800' },
      payload.datasets.map((ds) => {
        const radio = el('input', { type: 'radio', name: 'train-dataset', class: 'accent-blue-500 shrink-0' });
        radio.addEventListener('change', () => {
          state.dataset = ds;
        });
        return el('label', {
          class: 'flex items-center gap-3 px-3 py-2 text-xs cursor-pointer hover:bg-gray-800/40',
        }, [
          radio,
          el('span', { class: 'text-gray-200 font-mono w-64 truncate', text: ds.name }),
          el('span', { class: 'text-gray-400 w-20 text-right', text: `${ds.total_episodes} ${tr('train_episodes', 'episodes')}` }),
          el('span', { class: 'text-gray-500 w-24 text-right', text: `${thousands(ds.total_frames)} ${tr('train_new_frames', 'frames')}` }),
          el('span', { class: 'text-gray-600 w-12 text-right', text: ds.codebase_version }),
          el('span', { class: 'text-gray-600 ml-auto', text: ds.fps ? `${ds.fps} fps` : '' }),
        ]);
      })));
  }

  datasetHost.appendChild(loadingRow(tr('loading', 'Loading...')));
  listDatasets()
    .then(renderDatasets)
    .catch((err) => { clear(datasetHost); datasetHost.appendChild(errorRow(`${tr('train_load_failed', 'Failed to load')}: ${err.message}`)); });

  // 到点提醒是**加分项**:拿不到(比如扫描超时)就整块不显示,不拦这一页 ——
  // 它只是提示"该导出了",不是这一页的主功能。
  listReadyProjects()
    .then(renderReady)
    .catch(() => clear(readyHost));

  // ── ⑤ 从已有训练继续(可选)─────────────────────────
  const resumeTask = el('select', { class: INPUT_CLASS + ' w-64' }, [
    el('option', { value: '', text: tr('train_resume_none', '— Start fresh —') }),
  ]);
  const resumeStep = el('select', { class: INPUT_CLASS + ' w-40', disabled: true }, [
    el('option', { value: '', text: '—' }),
  ]);
  let runsCache = [];

  resumeTask.addEventListener('change', () => {
    clear(resumeStep);
    const run = runsCache.find((r) => r.name === resumeTask.value);
    if (!run) {
      resumeStep.disabled = true;
      resumeStep.appendChild(el('option', { value: '', text: '—' }));
      state.resume = null;
      return;
    }
    resumeStep.disabled = false;
    resumeStep.appendChild(el('option', { value: '', text: tr('train_resume_latest', '— latest —') }));
    for (const c of run.checkpoints) {
      resumeStep.appendChild(el('option', { value: String(c.step), text: `${c.name} (${bytes(c.total_bytes)})` }));
    }
    state.resume = { run: run.name, step: run.checkpoints[0]?.step ?? null };
  });
  resumeStep.addEventListener('change', () => {
    state.resume = resumeTask.value
      ? { run: resumeTask.value, step: resumeStep.value ? parseInt(resumeStep.value, 10) : null }
      : null;
  });

  const sectionResume = section('train_sec_resume', '5. Continue From a Previous Run',
    { cls: PENDING, text: tr('train_new_pending', 'not wired') }, [
      el('div', { class: 'flex gap-6 flex-wrap' }, [
        labeled(tr('train_resume_task', 'Source run'), resumeTask),
        labeled(tr('train_resume_step', 'Checkpoint'), resumeStep),
      ]),
    ]);

  listRuns()
    .then((payload) => {
      runsCache = payload.runs || [];
      for (const run of runsCache) {
        resumeTask.appendChild(el('option', { value: run.name, text: `${run.name} (${run.checkpoint_count})` }));
      }
    })
    .catch(() => { /* 拿不到就保持"全新训练",不拦整页 */ });



  // 展示顺序 ≠ 构造顺序。构造时必须先定义好各自依赖的变量(GPU 面板、数据集的
  // 留出集警告、命令生成…),这里才决定它们从上到下怎么排。
  //
  // 顺序:数据集 → 模型 → 算力 → 参数。先定"用什么数据",再定"训什么模型",
  // 再定"在哪训",最后"怎么训" —— 决策依赖就是按这个方向走的(比如数据集决定了
  // 命令里的 --dataset.root)。
  for (const node of [sectionDataset, sectionModel, sectionLocation, sectionParams,
                      sectionResume]) {
    container.appendChild(node);
  }
}