stouchinglhh
Claude
sync processor from GitLab c697b6e: 版本 1.19.4 / 前端 1.12.4 —— 服务端 i18n(首屏不再闪英文)、推理模块并入模型页、前端抽出 shared/、缓存键改用前端号
bb59211
Raw History Blame Contribute Delete
10.4 kB
/** 模型推理视图 —— 选模型/检查点、看它的训练参数、选测试集,然后跑评估。
*
* 与 `static/js/train/` 同构:一个入口 + 若干模块,共用 `static/js/shared/` 下的
* 纯工具。**不新开一套** —— 推理和训练本来就是同一批产物的上下游。
*
* **后端执行未接** —— 「运行推理」按钮和训练页一样是**故意禁用**的,旁边写明原因。
* 这一页真正能用的是前面三段(选模型 / 看参数 / 选测试集),数据都是真的:
* 模型与检查点 → GET /api/v1/training/runs
* 训练参数 → 那些 checkpoint 里的 train_config.json
* 测试集 → GET /api/v1/training/datasets
*
* 没有「实时日志」「指标曲线」之类的空页签 —— 没有数据的页签不摆,理由同训练页。
*
* ★ 数据源为什么直接引 `../train/api.js`:runs / datasets 这两个端点属于**训练域**
* (推理消费的是训练产物)。等将来有第三个页面也用到它们,再抽到 shared/ 去。
*/
import { el, clear } from '../shared/dom.js';
import { tr } from '../shared/text.js';
import { listDatasets, listRuns } from '../train/api.js';
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 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 hint(key, fallback) {
return el('div', { class: 'text-xs text-gray-600 mt-3', text: tr(key, fallback) });
}
/** 一行「参数名 → 值」。推理前最该确认的就是**训练时用的 chunk_size /
* n_action_steps** 这类会直接影响推理输出的参数,所以这里只挑这几个关键的,
* 完整的训练参数在训练页那个「训练参数」页签里有。 */
function paramRow(name, value) {
return el('div', { class: 'flex items-baseline gap-3 text-xs py-0.5' }, [
el('span', { class: 'text-gray-500 shrink-0 w-32 font-mono', text: name }),
el('span', { class: 'text-gray-200 font-mono break-all', text: value ?? '—' }),
]);
}
const KEY_PARAMS = [
'policy.type', 'policy.chunk_size', 'policy.n_action_steps',
'policy.dim_model', 'policy.vision_backbone',
];
function pick(obj, path) {
return path.split('.').reduce((a, k) => (a === null || a === undefined ? undefined : a[k]), obj);
}
/**
* @param {HTMLElement} container
* @param {{runName?: string, checkpointName?: string}} [preselect]
* 从「模型训练」页签点某个检查点的「推理」跳过来时带上 —— 直接选中它,
* 不用再手动在下拉里找一遍。
*/
export function renderInference(container, preselect) {
clear(container);
const state = { run: null, checkpoint: null, dataset: null };
// ── 选择模型 / 检查点 ────────────────────────────────
const runSelect = el('select', { class: INPUT_CLASS + ' w-64' });
const stepSelect = el('select', { class: INPUT_CLASS + ' w-40', disabled: true });
const paramHost = el('div', { class: 'mt-3' });
let runs = [];
function renderCheckpointParams() {
clear(paramHost);
const config = state.checkpoint?.train_config;
if (!config || !Object.keys(config).length) {
paramHost.appendChild(el('div', {
class: 'text-xs text-gray-600 py-2',
text: tr('inf_no_params', 'This checkpoint has no train_config.json; parameters unavailable.'),
}));
return;
}
paramHost.appendChild(el('div', {
class: 'text-[10px] text-gray-600 mb-2',
text: `${tr('inf_params_from', 'From')} checkpoints/${state.checkpoint.name}/pretrained_model/train_config.json`,
}));
paramHost.appendChild(el('div', { class: 'border border-gray-800 rounded p-3' },
KEY_PARAMS.map((p) => paramRow(p, pick(config, p)))));
}
function fillSteps(wantedStep) {
clear(stepSelect);
const run = runs.find((r) => r.name === runSelect.value);
if (!run) {
stepSelect.disabled = true;
stepSelect.appendChild(el('option', { value: '', text: '—' }));
state.checkpoint = null;
renderCheckpointParams();
return;
}
stepSelect.disabled = false;
for (const c of run.checkpoints) {
stepSelect.appendChild(el('option', { value: c.name, text: `${c.name} (${c.step})` }));
}
// 有预选就用预选,否则用最新的那个。预选名可能对不上(比如那个 checkpoint
// 刚被删),对不上就退回最新 —— 不要因为一个过期的参数让整页空着。
const pick = (wantedStep && run.checkpoints.find((c) => c.name === wantedStep))
|| run.checkpoints[0] || null;
state.checkpoint = pick;
if (pick) stepSelect.value = pick.name;
renderCheckpointParams();
}
runSelect.addEventListener('change', () => {
state.run = runSelect.value || null;
fillSteps();
});
stepSelect.addEventListener('change', () => {
const run = runs.find((r) => r.name === runSelect.value);
state.checkpoint = (run?.checkpoints || []).find((c) => c.name === stepSelect.value) || null;
renderCheckpointParams();
});
const sectionModel = section('inf_sec_model', '1. Model', null, [
el('div', { class: 'flex gap-6 flex-wrap' }, [
labeled(tr('inf_label_run', 'Run'), runSelect),
labeled(tr('inf_label_step', 'Checkpoint'), stepSelect),
]),
paramHost,
]);
paramHost.appendChild(el('div', { class: 'text-xs text-gray-600 py-2', text: tr('loading', 'Loading...') }));
listRuns()
.then((payload) => {
runs = payload.runs || [];
clear(runSelect);
if (!runs.length) {
runSelect.appendChild(el('option', { value: '', text: tr('inf_no_models', 'No trained model yet') }));
renderCheckpointParams();
return;
}
for (const r of runs) {
runSelect.appendChild(el('option', { value: r.name, text: `${r.name} (${r.checkpoint_count})` }));
}
const chosen = runs.find((r) => r.name === preselect?.runName) || runs[0];
runSelect.value = chosen.name;
state.run = chosen.name;
fillSteps(preselect?.checkpointName);
})
.catch((err) => {
clear(paramHost);
paramHost.appendChild(el('div', {
class: 'text-xs text-red-400 py-2',
text: `${tr('train_load_failed', 'Failed to load')}: ${err.message}`,
}));
});
// ── 测试集 ──────────────────────────────────────────
const datasetSelect = el('select', { class: INPUT_CLASS + ' w-80' }, [
el('option', { value: '', text: tr('inf_dataset_none', '— Select a test dataset —') }),
]);
datasetSelect.addEventListener('change', () => {
const value = datasetSelect.value;
state.dataset = value ? { name: value } : null;
});
const datasetNote = el('div', { class: 'text-xs text-gray-600 mt-2' });
const sectionData = section('inf_sec_data', '2. Test Data',
{ cls: LIVE, text: tr('inf_live', 'live') }, [
labeled(tr('inf_label_dataset', 'Test dataset'), datasetSelect),
datasetNote,
]);
datasetNote.textContent = tr('loading', 'Loading...');
listDatasets()
.then((payload) => {
const items = payload.datasets || [];
datasetNote.textContent = items.length
? `${items.length} ${tr('inf_datasets_available', 'dataset(s) available')}`
: tr('inf_no_datasets', 'No exported dataset yet — export one from the review page first.');
for (const d of items) {
datasetSelect.appendChild(el('option', {
value: d.name,
text: `${d.name} — ${d.total_episodes} ${tr('train_episodes', 'episodes')}`,
}));
}
})
.catch(() => { datasetNote.textContent = ''; });
// ── 运行 ────────────────────────────────────────────
const sectionRun = section('inf_sec_run', '3. Run', { cls: PENDING, text: tr('inf_pending', 'not wired') }, [
el('div', { class: 'flex items-center gap-3' }, [
el('button', {
class: 'bg-blue-600/40 text-blue-200/60 text-sm px-5 py-2 rounded cursor-not-allowed',
text: tr('inf_run_btn', 'Run inference'),
disabled: true,
title: tr('inf_run_disabled', 'Backend execution is not wired up yet'),
}),
el('span', {
class: 'text-xs text-amber-400',
text: tr('inf_run_note', 'Backend execution is not connected yet — this button is intentionally disabled.'),
}),
]),
hint('inf_run_hint', 'Once wired, this runs the checkpoint over the chosen test set and reports direction cosine, amplitude ratio, gripper MAE and the error curve.'),
]);
// ── 结果 ────────────────────────────────────────────
const sectionResult = section('inf_sec_result', '4. Results', null, [
el('div', { class: 'text-center py-8' }, [
el('div', { class: 'text-xs text-gray-600', text: tr('inf_no_result', 'No evaluation result yet.') }),
]),
]);
for (const node of [sectionModel, sectionData, sectionRun, sectionResult]) {
container.appendChild(node);
}
}
// 这里**没有** boot/自启动 —— 本模块不再是页面入口,而是被 train/main.js
// 当作第三个页签 import 进去(`render: renderInference`)。
// 页签的 render 签名是 (panel, run),本函数只用到 panel,多收一个参数无妨。