feat(web-platform): release V0.9.3 全模块界面重构
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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&B</option>
|
||||
<option value="online">在线 W&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&B</option>
|
||||
<option value="online">在线 W&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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user