feat(web-platform): release V0.9.3 全模块界面重构
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled

This commit is contained in:
2026-09-08 17:57:57 +08:00
parent 831d0b95bb
commit 13e35be98b
124 changed files with 5182 additions and 2141 deletions
@@ -225,7 +225,7 @@ function customServer(job?: unknown) {
async function connectCustom() {
fireEvent.change(screen.getByLabelText('训练服务访问令牌'), { target: { value: 'token' } });
fireEvent.click(screen.getByRole('button', { name: '连接' }));
await screen.findByText('/local');
await screen.findByText('训练服务已连接');
}
describe('LocalTrainingPanel 自定义任务', () => {
it('动态任务/地图/传感器配置传入payload,切换任务清掉sensor与reward状态', async () => {
@@ -236,16 +236,19 @@ describe('LocalTrainingPanel 自定义任务', () => {
target: { value: 'Unitree-Go2-ObstacleAvoidance' },
});
expect(screen.getByLabelText('训练地形')).toHaveValue('discrete_obstacles');
fireEvent.click(screen.getByRole('button', { name: '实验高级配置' }));
expect(screen.getByLabelText('奖励配置 preset')).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '避障传感器高级设置' }));
fireEvent.change(screen.getByLabelText('感知角 FOV'), { target: { value: '60' } });
fireEvent.change(screen.getByLabelText('训练地形'), { target: { value: 'rough' } });
expect(screen.getByText(/训练专用 box/)).toBeInTheDocument();
expect(screen.getByText(/近似地形:/)).toBeVisible();
fireEvent.change(screen.getByLabelText('训练任务'), { target: { value: 'Unitree-Go2-Flat' } });
expect(screen.queryByLabelText('感知角 FOV')).not.toBeInTheDocument();
expect(screen.getByLabelText('训练地形')).toHaveValue('');
fireEvent.change(screen.getByLabelText('训练任务'), {
target: { value: 'Unitree-Go2-ObstacleAvoidance' },
});
fireEvent.click(screen.getByRole('button', { name: '避障传感器高级设置' }));
expect(screen.getByLabelText('感知角 FOV')).toHaveValue(90);
fireEvent.change(screen.getByLabelText('感知角 FOV'), { target: { value: '60' } });
fireEvent.click(screen.getByRole('button', { name: '发起本地训练' }));
@@ -267,7 +270,7 @@ describe('LocalTrainingPanel 自定义任务', () => {
const fetchMock = customServer();
render(<LocalTrainingPanel onPolicyReady={vi.fn()} />);
await connectCustom();
fireEvent.click(screen.getByRole('button', { name: '检查当前场景地图(可选)' }));
fireEvent.click(screen.getByRole('button', { name: '检查场景地图' }));
expect(screen.getByRole('alert')).toHaveTextContent('没有已应用');
fireEvent.change(screen.getByLabelText('训练地形'), { target: { value: 'plane' } });
fireEvent.change(screen.getByLabelText('地图尺寸 m'), { target: { value: '100' } });
@@ -275,6 +278,24 @@ describe('LocalTrainingPanel 自定义任务', () => {
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('超出允许范围'));
expect(fetchMock.mock.calls.some((call) => call[1]?.method === 'POST')).toBe(false);
});
it('高级输入折叠后仍保留,提交校验错误强制展开所属详情且不发训练请求', async () => {
const fetchMock = customServer();
render(<LocalTrainingPanel onPolicyReady={vi.fn()} />);
await connectCustom();
fireEvent.change(screen.getByLabelText('训练地形'), { target: { value: 'plane' } });
const details = screen.getByRole('button', { name: '地形详细参数' });
expect(details).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(details);
fireEvent.change(screen.getByLabelText('地图尺寸 m'), { target: { value: '100' } });
fireEvent.click(details);
expect(screen.getByLabelText('地图尺寸 m')).not.toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '发起本地训练' }));
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('超出允许范围'));
expect(details).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByLabelText('地图尺寸 m')).toBeVisible();
expect(screen.getByLabelText('地图尺寸 m')).toHaveValue(100);
expect(fetchMock.mock.calls.some((call) => call[1]?.method === 'POST')).toBe(false);
});
it('旧Rough作业禁用导入,不误当Flat', async () => {
customServer({
id: 'r'.repeat(32),
@@ -291,7 +312,7 @@ describe('LocalTrainingPanel 自定义任务', () => {
render(<LocalTrainingPanel onPolicyReady={vi.fn()} />);
await connectCustom();
expect(await screen.findByRole('button', { name: '导入策略' })).toBeDisabled();
expect(screen.getByText(/234维 Rough/)).toBeInTheDocument();
expect(screen.getByText(/仅后端评测:/)).toBeInTheDocument();
});
});
@@ -326,7 +347,10 @@ describe('LocalTrainingPanel 配套策略交接', () => {
expect(screen.getByLabelText('训练地形')).toHaveValue('custom_boxes');
expect(screen.queryByLabelText('出生 X')).not.toBeInTheDocument();
expect(screen.queryByLabelText('目标 Y')).not.toBeInTheDocument();
expect(screen.getByText(/旋转障碍会膨胀/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: '检查场景地图' })).toHaveAttribute(
'title',
expect.stringContaining('旋转障碍会膨胀'),
);
fireEvent.click(screen.getByRole('button', { name: '发起本地训练' }));
await waitFor(() =>
expect(fetchMock.mock.calls.some((call) => call[1]?.method === 'POST')).toBe(true),
@@ -396,13 +420,13 @@ it('custom_boxes拒绝未应用草稿,且训练时自动重新编译最新地
/>,
);
await connectCustom();
fireEvent.click(screen.getByRole('button', { name: '检查当前场景地图(可选)' }));
fireEvent.click(screen.getByRole('button', { name: '检查场景地图' }));
expect(screen.getByRole('alert')).toHaveTextContent('草稿');
expect(compileScene).not.toHaveBeenCalled();
rerender(
<LocalTrainingPanel onPolicyReady={vi.fn()} sceneMaps={assets} compileScene={compileScene} />,
);
fireEvent.click(screen.getByRole('button', { name: '检查当前场景地图(可选)' }));
fireEvent.click(screen.getByRole('button', { name: '检查场景地图' }));
expect(screen.queryByLabelText('出生 X')).not.toBeInTheDocument();
expect(screen.queryByLabelText('目标 X')).not.toBeInTheDocument();
current = { ...current, boxes: [current.boxes[0], { ...current.boxes[1], pos: [1, 3, 0.5] }] };
@@ -424,6 +448,7 @@ it('显式选择multi48传入训练请求,默认与切换任务仍single32', a
fireEvent.change(screen.getByLabelText('训练任务'), {
target: { value: 'Unitree-Go2-ObstacleAvoidance' },
});
fireEvent.click(screen.getByRole('button', { name: '避障传感器高级设置' }));
expect(screen.getByLabelText('传感器模式')).toHaveValue('single_ring_raycast');
fireEvent.change(screen.getByLabelText('传感器模式'), {
target: { value: 'multi_ring_raycast' },
@@ -458,6 +483,7 @@ it('Flat奖励菜单只展示服务确认归属Flat的preset,Obstacle和无身
);
render(<LocalTrainingPanel onPolicyReady={vi.fn()} />);
await connectCustom();
fireEvent.click(screen.getByRole('button', { name: '实验高级配置' }));
expect(await screen.findByRole('option', { name: '合法历史Flat' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'Obstacle不能混入' })).not.toBeInTheDocument();
expect(screen.queryByRole('option', { name: '缺少权威身份' })).not.toBeInTheDocument();
+163 -91
View File
@@ -13,7 +13,15 @@ import type { TrainingSceneCompiler } from '../map/trainingMap';
import type { PlacedMapAsset } from '../map/types';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { Download, ExternalLink, Link, Play, Server, Square } from 'lucide-react';
import { Badge, Button, ProgressBar, PropertyRow, Select } from '../components/ui';
import {
Badge,
Button,
ProgressBar,
PropertyRow,
Select,
CollapsibleSection,
Tooltip,
} from '../components/ui';
import { LocalTrainingClient } from './LocalTrainingClient';
import type {
RewardPreset,
@@ -51,11 +59,13 @@ export function LocalTrainingPanel({
compileScene,
sceneMaps = [],
sceneDirty = false,
onStatusChange,
}: {
onPolicyReady(file: File, deployment?: PolicyDeployment): void | Promise<void>;
compileScene?: TrainingSceneCompiler;
sceneMaps?: readonly PlacedMapAsset[];
sceneDirty?: boolean;
onStatusChange?: (summary: string) => void;
}) {
const [endpoint, setEndpoint] = useState(() =>
localStored(TRAINING_ENDPOINT_KEY, DEFAULT_TRAINING_ENDPOINT),
@@ -70,6 +80,10 @@ export function LocalTrainingPanel({
const [connectionRevision, setConnectionRevision] = useState(0);
const [busy, setBusy] = useState(false),
[error, setError] = useState<string>();
const errorNotice = useRef<HTMLParagraphElement>(null);
useEffect(() => {
if (error) errorNotice.current?.scrollIntoView?.({ block: 'nearest' });
}, [error]);
const [taskId, setTaskId] = useState('Unitree-Go2-Flat'),
[numEnvs, setNumEnvs] = useState(4096),
[maxIterations, setMaxIterations] = useState(2000),
@@ -445,9 +459,22 @@ export function LocalTrainingPanel({
}
};
const active = Boolean(job && ACTIVE_STATES.has(job.state));
const summary = error ? '错误' : job ? stateLabel(job.state) : server?.ready ? '已连接' : '离线';
useEffect(() => {
onStatusChange?.(summary);
}, [onStatusChange, summary]);
return (
<div>
{error && (
<p
ref={errorNotice}
role="alert"
className="mb-2 break-words rounded border border-danger bg-panel p-2 text-xs leading-4 text-danger"
>
{error}
</p>
)}
<label className="block text-xs text-text-secondary">
<span className="mb-1 block">本地训练服务</span>
<div className="flex gap-2">
@@ -471,8 +498,8 @@ export function LocalTrainingPanel({
</Button>
</div>
</label>
<label className="mt-2 block text-[10px] text-text-tertiary">
<span className="mb-1 block">访问令牌(服务启动时显示)</span>
<label className="mt-2 block text-xs text-text-tertiary">
<span className="mb-1 block">访问令牌</span>
<input
aria-label="训练服务访问令牌"
type="password"
@@ -487,15 +514,24 @@ export function LocalTrainingPanel({
}}
/>
</label>
<div className="mt-2 flex items-center justify-between rounded-md border border-border bg-surface px-2 py-1.5 text-[10px] text-text-tertiary">
<div className="mt-2 flex items-center justify-between rounded-md border border-border bg-surface px-2 py-1.5 text-xs text-text-tertiary">
<span className="flex min-w-0 items-center gap-1.5 truncate">
<Server className="h-3.5 w-3.5" />
{server?.trainerRoot ?? '请先启动本地训练服务'}
{server?.ready ? '训练服务已连接' : '请先启动本地训练服务'}
</span>
<Badge tone={server?.ready ? 'success' : 'warning'}>
{server?.ready ? '可用' : '离线'}
</Badge>
</div>
{server && (
<details className="domain-details">
<summary>连接详情</summary>
<PropertyRow label="训练目录" value={server.trainerRoot} />
<p className="text-xs text-text-secondary">
令牌由服务启动时提供,仅保存在当前浏览器会话。
</p>
</details>
)}
<Button
className="mt-2 w-full"
icon={<ExternalLink className="h-3.5 w-3.5" />}
@@ -566,12 +602,13 @@ export function LocalTrainingPanel({
))}
</Select>
</Field>
<Button disabled={busy} onClick={syncMap}>
检查当前场景地图(可选)
<Button
disabled={busy}
title="从已应用实例编译并校验世界 AABB;旋转障碍会膨胀为轴对齐包围盒"
onClick={syncMap}
>
检查场景地图
</Button>
<p className="text-[10px] text-text-tertiary">
启动训练时会自动从全部已应用实例重新编译并校验世界AABB,无需预先同步。旋转障碍会膨胀,底板标准化为z=[-0.2,0];仅保证训练与浏览器使用相同boxes,不等于原OBB。mesh/hfield、地下结构、混合摩擦明确拒绝。
</p>
{terrainPreset === 'custom_boxes' && customTerrainBoxes && !sceneDirty && (
<p role="status">
已读取视口中 {customTerrainBoxes.actualObstacleCount}{' '}
@@ -579,26 +616,47 @@ export function LocalTrainingPanel({
</p>
)}
{terrainPreset && terrainPreset !== 'custom_boxes' && (
<div className="grid grid-cols-2 gap-2">
{Object.entries(metadata.terrainParameters).map(([key, bounds]) => (
<NumberField
key={key}
label={PARAMETER_LABELS[key] ?? key}
value={terrainParams[key] ?? bounds.default}
min={bounds.min}
max={bounds.max}
step={bounds.integer ? 1 : 0.01}
onChange={(value) => setTerrainParams((old) => ({ ...old, [key]: value }))}
/>
))}
</div>
<CollapsibleSection
title="地形详细参数"
defaultOpen={false}
keepMounted
forceOpen={Boolean(error)}
>
<div className="grid grid-cols-2 gap-2">
{Object.entries(metadata.terrainParameters).map(([key, bounds]) => (
<NumberField
key={key}
label={PARAMETER_LABELS[key] ?? key}
value={terrainParams[key] ?? bounds.default}
defaultValue={bounds.default}
min={bounds.min}
max={bounds.max}
step={bounds.integer ? 1 : 0.01}
onChange={(value) => setTerrainParams((old) => ({ ...old, [key]: value }))}
/>
))}
</div>
</CollapsibleSection>
)}
{['rough', 'wave', 'pyramid_stairs'].includes(terrainPreset) && (
<p>训练专用 box 离散近似布局,不等于编辑器高度场。</p>
<div title="训练使用 box 离散近似布局,不等于编辑器高度场">
<p className="text-xs text-warning">
近似地形:训练使用 box 离散布局,非编辑器高度场。
</p>
</div>
)}
{taskId === OBSTACLE_TASK_ID && (
<details open>
<summary>避障传感器高级设置</summary>
<p className="text-xs text-text-secondary">
{sensorMode === 'single_ring_raycast' ? '水平32射线 · 81维' : '三层48射线 · 97维'}
</p>
)}
{taskId === OBSTACLE_TASK_ID && (
<CollapsibleSection
title="避障传感器高级设置"
defaultOpen={false}
keepMounted
forceOpen={Boolean(error)}
>
<Field label="传感器模式">
<Select
aria-label="传感器模式"
@@ -619,16 +677,19 @@ export function LocalTrainingPanel({
key={key}
label={PARAMETER_LABELS[key] ?? key}
value={sensorCfg[key] ?? bounds.default}
defaultValue={bounds.default}
min={bounds.min}
max={bounds.max}
step={0.01}
onChange={(value) => setSensorCfg((old) => ({ ...old, [key]: value }))}
/>
))}
</details>
</CollapsibleSection>
)}
{!metadata.browserCompatible && (
<p>此任务可训练,但浏览器不支持其观测契约,不能一键部署。</p>
<div title="浏览器不支持当前观测契约,策略仅可在后端评测">
<p className="text-xs text-warning">仅后端部署:浏览器不支持当前观测契约。</p>
</div>
)}
</>
)}
@@ -685,36 +746,43 @@ export function LocalTrainingPanel({
/>
</Field>
</div>
<Field label="奖励配置">
<Select
disabled={taskId !== 'Unitree-Go2-Flat'}
aria-label="奖励配置 preset"
className="w-full"
value={rewardPresetId}
onChange={(event) => setRewardPresetId(event.target.value)}
>
<option value="">仓库默认奖励</option>
{presets
.filter((preset) => preset.taskId === 'Unitree-Go2-Flat')
.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.name}
</option>
))}
</Select>
</Field>
<Field label="实验记录">
<Select
aria-label="W&B 模式"
className="w-full"
value={wandbMode}
onChange={(event) => setWandbMode(event.target.value as WandbMode)}
>
<option value="offline">本地离线(默认,无需登录)</option>
<option value="disabled">完全禁用 W&amp;B</option>
<option value="online">在线 W&amp;B(需要 API Key)</option>
</Select>
</Field>
<CollapsibleSection
title="实验高级配置"
defaultOpen={false}
keepMounted
forceOpen={Boolean(error)}
>
<Field label="奖励配置">
<Select
disabled={taskId !== 'Unitree-Go2-Flat'}
aria-label="奖励配置 preset"
className="w-full"
value={rewardPresetId}
onChange={(event) => setRewardPresetId(event.target.value)}
>
<option value="">仓库默认奖励</option>
{presets
.filter((preset) => preset.taskId === 'Unitree-Go2-Flat')
.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.name}
</option>
))}
</Select>
</Field>
<Field label="实验记录">
<Select
aria-label="W&B 模式"
className="w-full"
value={wandbMode}
onChange={(event) => setWandbMode(event.target.value as WandbMode)}
>
<option value="offline">本地离线(默认,无需登录)</option>
<option value="disabled">完全禁用 W&amp;B</option>
<option value="online">在线 W&amp;B(需要 API Key)</option>
</Select>
</Field>
</CollapsibleSection>
<Button
variant="primary"
className="w-full"
@@ -724,14 +792,10 @@ export function LocalTrainingPanel({
>
发起本地训练
</Button>
<p className="text-[10px] leading-4 text-text-tertiary">
训练使用本地 mjlab
任务资产,不会把浏览器中的模型上传到网络。服务一次只运行一个训练任务。
</p>
</fieldset>
)}
{job && (
<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
<div className="mt-3 border-t border-border pt-3">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="truncate text-xs font-medium text-text-primary" title={job.id}>
{job.taskId}
@@ -750,19 +814,28 @@ export function LocalTrainingPanel({
</div>
<PretrainedIdentity source={job.pretrained} />
{job.taskId === 'Unitree-Go2-Rough' && (
<p>234维 Rough 策略仅支持后端评测,浏览器不可加载。</p>
<div>
<p className="text-xs text-warning">
仅后端评测:234 维 Rough 策略无法在浏览器加载。
</p>
</div>
)}
{job.deployment?.terrain && (
<p className="text-[10px] text-text-tertiary">
导入将替换当前物理地图并启动配套策略;
{job.deployment.terrain.approximation ? '训练专用近似布局' : '配套碰撞布局'}
。请先保存场景。
</p>
<div>
<p className="text-xs text-warning">
导入会替换当前物理地图并启动配套策略,请先保存场景。
</p>
<Badge tone="warning">
{job.deployment.terrain.approximation ? '近似碰撞布局' : '配套碰撞布局'}
</Badge>
</div>
)}
<ProgressBar value={job.progress} label="训练进度" />
<div className="mt-2">
<PropertyRow label="迭代" value={`${job.iteration} / ${job.maxIterations}`} />
<PropertyRow label="状态" value={job.message} />
<p role="status" className="break-words text-xs text-text-secondary">
{job.message}
</p>
{trainingLosses(job.logs).map(({ label, value }) => (
<PropertyRow key={label} label={label} value={value} />
))}
@@ -770,8 +843,8 @@ export function LocalTrainingPanel({
<TrainingMetricsPanel key={job.id} jobId={job.id} logs={job.logs} />
{job.logs.length > 0 && (
<details className="mt-2">
<summary className="cursor-pointer text-[10px] text-text-secondary">最近日志</summary>
<pre className="mt-1 max-h-36 overflow-auto whitespace-pre-wrap break-all rounded bg-app p-2 text-[9px] leading-4 text-text-tertiary">
<summary className="cursor-pointer text-xs text-text-secondary">最近日志</summary>
<pre className="mt-1 max-h-36 overflow-auto whitespace-pre-wrap break-all rounded bg-app p-2 text-xs leading-4 text-text-tertiary">
{job.logs.slice(-40).join('\n')}
</pre>
</details>
@@ -818,21 +891,13 @@ export function LocalTrainingPanel({
</div>
</div>
)}
{error && (
<p
role="alert"
className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] leading-4 text-danger"
>
{error}
</p>
)}
</div>
);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="block text-[10px] text-text-tertiary">
<label className="block text-xs text-text-tertiary">
<span className="mb-1 block">{label}</span>
{children}
</label>
@@ -845,26 +910,33 @@ function NumberField({
max,
onChange,
step = 1,
defaultValue,
}: {
label: string;
value: number;
min: number;
max: number;
step?: number;
defaultValue?: number;
onChange(value: number): void;
}) {
return (
<Field label={label}>
<input
aria-label={label}
type="number"
step={step}
className="field h-7 w-full px-2 text-xs text-text-primary"
value={value}
min={min}
max={max}
onChange={(event) => onChange(Number(event.target.value))}
/>
<Tooltip
className="w-full"
content={`范围 ${min}–${max}${defaultValue === undefined ? '' : `;默认 ${defaultValue}`}`}
>
<input
aria-label={label}
type="number"
step={step}
className="field h-7 w-full px-2 text-xs tabular-nums text-text-primary"
value={value}
min={min}
max={max}
onChange={(event) => onChange(Number(event.target.value))}
/>
</Tooltip>
</Field>
);
}
@@ -11,27 +11,31 @@ export function PretrainedIdentity({ source }: { source?: PretrainedInitializati
<p>
初始化:{source.label} · {source.manifest.artifacts.checkpoint.name}
</p>
<p>checkpoint SHA256:{source.manifest.artifacts.checkpoint.sha256}</p>
{source.manifest.artifacts.upload ? (
<>
<p>
上传格式:{source.manifest.sourceFormat} · 原文件:{source.label}
</p>
<p>原文件 SHA256:{source.manifest.artifacts.upload.sha256}</p>
<p>
模板:{source.manifest.contract}
(用户确认缺失的物理语义);继承actor权重,非完整resume。
</p>
{source.manifest.sourceFormat === 'onnx' && (
<details className="domain-details">
<summary>来源与校验详情</summary>
<p>checkpoint SHA256:{source.manifest.artifacts.checkpoint.sha256}</p>
{source.manifest.artifacts.upload ? (
<>
<p>
ONNX统计count合成:{source.manifest.derived_fields?.normalizer_count?.value ?? '未知'}
; 探索std使用新训练默认,critic/optimizer重新初始化。
上传格式:{source.manifest.sourceFormat} · 原文件:{source.label}
</p>
)}
</>
) : (
<p>ONNX SHA256:{source.manifest.artifacts.onnx?.sha256 ?? '未知'}</p>
)}
<p>原文件 SHA256:{source.manifest.artifacts.upload.sha256}</p>
<p>
模板:{source.manifest.contract}
(用户确认缺失的物理语义);继承actor权重,非完整resume。
</p>
{source.manifest.sourceFormat === 'onnx' && (
<p>
ONNX统计count合成:
{source.manifest.derived_fields?.normalizer_count?.value ?? '未知'};
探索std使用新训练默认,critic/optimizer重新初始化。
</p>
)}
</>
) : (
<p>ONNX SHA256:{source.manifest.artifacts.onnx?.sha256 ?? '未知'}</p>
)}
</details>
<p>
来源迭代 {source.manifest.source_iteration ?? '未知'}
;新训练从0开始,critic/optimizer重新初始化;同trial续训保留自身checkpoint。
@@ -97,9 +101,7 @@ export function PretrainedSourceSelect({
disabled={disabled}
/>
)}
{!sources.length && (
<p className="text-xs">尚无基础策略,可直接上传单个文件;不选择则随机初始化。</p>
)}
{!sources.length && <p className="text-xs text-text-secondary">尚无基础策略</p>}
{sources
.filter((source) => source.error)
.map((source) => (
+32 -29
View File
@@ -76,36 +76,39 @@ export function PretrainedUpload({
setError('');
};
return (
<div className="space-y-2 rounded border border-border p-2 text-xs">
<p>上传基础策略:单个.pt(≤256MiB)或.onnx(≤64MiB),无需服务器路径或配套文件。</p>
<label className="flex gap-2">
<details className="domain-details text-xs" open={pending || Boolean(error) || undefined}>
<summary>上传基础策略{pending ? ' · 验证中' : ''}</summary>
<div className="space-y-2">
<p>上传基础策略:单个.pt(≤256MiB)或.onnx(≤64MiB),无需服务器路径或配套文件。</p>
<label className="flex gap-2">
<input
type="checkbox"
aria-label="确认Go2 legacy47模板"
checked={confirmed}
disabled={disabled || pending || !connection.enabled}
onChange={(event) => setConfirmed(event.target.checked)}
/>
按Go2 legacy47观测及FL/FR/RL/RR关节顺序解释文件;缺失的物理语义由我确认。
</label>
<p>
仅支持47维Go2行走actor;ONNX仅继承推理网络,统计count合成,探索/价值网络/优化器重新初始化。
</p>
<input
type="checkbox"
aria-label="确认Go2 legacy47模板"
checked={confirmed}
disabled={disabled || pending || !connection.enabled}
onChange={(event) => setConfirmed(event.target.checked)}
type="file"
accept=".pt,.onnx"
aria-label="选择基础策略文件"
disabled={!confirmed || disabled || pending || !connection.enabled}
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = ''; // Same-file retry must still dispatch change.
if (file) void upload(file);
}}
/>
按Go2 legacy47观测及FL/FR/RL/RR关节顺序解释文件;缺失的物理语义由我确认。
</label>
<p>
仅支持47维Go2行走actor;ONNX仅继承推理网络,统计count合成,探索/价值网络/优化器重新初始化。
</p>
<input
type="file"
accept=".pt,.onnx"
aria-label="选择基础策略文件"
disabled={!confirmed || disabled || pending || !connection.enabled}
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = ''; // Same-file retry must still dispatch change.
if (file) void upload(file);
}}
/>
{!connection.enabled && <p>请先连接支持单文件上传的训练服务。</p>}
{pending && <Button onClick={cancel}>取消上传</Button>}
{message && <p role="status">{message}</p>}
{error && <p role="alert">{error}</p>}
</div>
{!connection.enabled && <p>请先连接支持单文件上传的训练服务。</p>}
{pending && <Button onClick={cancel}>取消上传</Button>}
{message && <p role="status">{message}</p>}
{error && <p role="alert">{error}</p>}
</div>
</details>
);
}
@@ -1,4 +1,5 @@
import { memo, useEffect, useState } from 'react';
import { Tooltip } from '../components/ui';
import { ScalarChart } from '../components/charts/ScalarChart';
import { TrainingMetricHistory, TRAINING_METRICS } from './TrainingMetricHistory';
import type { ScalarSeries } from './types';
@@ -34,7 +35,7 @@ export const TrainingMetricsPanel = memo(function TrainingMetricsPanel({
item.tag === (tab === 'value' ? TRAINING_METRICS.value : TRAINING_METRICS.surrogate),
);
return (
<section className="mt-3 min-w-0 rounded-lg border border-border">
<section className="mt-3 min-w-0 border-t border-border">
<button
type="button"
className="w-full p-2 text-left text-xs text-text-primary"
@@ -70,10 +71,14 @@ export const TrainingMetricsPanel = memo(function TrainingMetricsPanel({
<p className="text-xs text-text-tertiary">尚无带迭代编号的指标日志</p>
)}
</div>
<p className="text-[10px] text-text-tertiary">
各指标独立纵轴;EMA 0.4
仅用于曲线,悬停显示原值。最多保留最近500个有指标的迭代,重连仅恢复服务端日志尾部。
</p>
<Tooltip content="独立纵轴;EMA 0.4仅平滑曲线,悬停显示原值。保留最近500个迭代,重连只恢复日志尾部。">
<button
type="button"
className="min-h-7 text-xs text-text-secondary underline decoration-dotted"
>
曲线说明
</button>
</Tooltip>
</div>
)}
</section>