feat: release v1.0.1 CADWorld 网站与 LeKiwi 智能抓放
web-platform-ci / Standalone decision service (no cloud credentials) (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
lekiwi-compatibility / cpu-compatibility (push) Has been cancelled
web-platform-ci / Standalone decision service (no cloud credentials) (pull_request) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled
lekiwi-compatibility / cpu-compatibility (pull_request) Has been cancelled

集成同源 BYOK 会话隔离、精简模型设置、官方订阅入口和 HTTPS 发布运维;保留本地训练/调参与控制能力。同步 npm 版本及 CHANGELOG,记录公网真实 API 验收仍待用户凭据。
This commit is contained in:
2026-09-24 09:57:41 +08:00
parent 3ad29356c9
commit f3a8a38acd
194 changed files with 32918 additions and 236 deletions
@@ -4,6 +4,21 @@ import { LocalTrainingClient } from './LocalTrainingClient';
afterEach(() => vi.unstubAllGlobals());
describe('LocalTrainingClient', () => {
it('移动场景二进制上传与部署元数据均携带认证,作业请求只传服务器快照ID', async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response('{}')));
vi.stubGlobal('fetch', fetchMock);
const client = new LocalTrainingClient('http://localhost:8765', 'secret');
const file = new File(['scene'], 'scene.zip');
await client.uploadMobileScene(file);
await client.downloadMobileDeployment('a'.repeat(32));
expect(fetchMock.mock.calls[0][0]).toContain('/api/training/mobile-packages');
const options = fetchMock.mock.calls[0][1] as RequestInit;
expect(options.body).toBe(file);
expect(new Headers(options.headers).get('Content-Type')).toBe('application/zip');
expect(new Headers(options.headers).get('Authorization')).toBe('Bearer secret');
expect(fetchMock.mock.calls[1][0]).toContain('/artifacts/deployment.json');
expect(() => client.uploadMobileScene(new File([], 'empty.zip'))).toThrow('128 MiB');
});
it('规范化服务地址并提交受类型约束的 JSON 请求', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ id: 'a'.repeat(32), state: 'queued' }), {
@@ -1,3 +1,4 @@
import type { MobileDeployment } from '../mobile/training';
import type {
ParameterConstraint,
PretrainedSource,
@@ -79,6 +80,20 @@ export class LocalTrainingClient {
});
}
start(request: TrainingRequest): Promise<TrainingJob> {
return this.startJob(request);
}
uploadMobileScene(file: File): Promise<{ id: string; robotId: string; sceneSha256: string }> {
if (!file.size || file.size > 128 * 1024 ** 2) throw new Error('场景快照上限 128 MiB');
return this.json('/api/training/mobile-packages', {
method: 'POST',
headers: { 'Content-Type': 'application/zip' },
body: file,
});
}
downloadMobileDeployment(id: string): Promise<MobileDeployment> {
return this.json(`/api/training/jobs/${encodeURIComponent(id)}/artifacts/deployment.json`);
}
startJob(request: TrainingRequest): Promise<TrainingJob> {
return this.json('/api/training/jobs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -0,0 +1,182 @@
import { fireEvent, render, screen, waitFor, act } from '@testing-library/react';
import { beforeEach, expect, it, vi } from 'vitest';
import { LocalTrainingPanel } from './LocalTrainingPanel';
import { LocalTrainingClient } from './LocalTrainingClient';
import { MOBILE_TRAINING_TASKS, type MobileDeployment } from '../mobile/training';
import type { TrainingJob, TrainingServerInfo } from './types';
const taskId = 'MobileManipulator-LeKiwi-Bundle';
const deployment = {
trainingTaskId: taskId,
robotId: 'lekiwi-bundle',
browserCompatible: true,
trainingStage: 'navigate',
evaluation: {
episodes: 10,
successRate: 0.6,
safetyStops: 0,
maxJointVelocity: 1.1,
meanNavigationDistance: 0.1,
seed: 100042,
},
} as MobileDeployment;
const complete: TrainingJob = {
id: 'a'.repeat(32),
taskId,
state: 'succeeded',
createdAt: '',
iteration: 2,
maxIterations: 2,
progress: 1,
message: '完成',
artifactReady: true,
deployment,
logs: [
'Learning iteration 2 / 2',
'Mean value loss: 0.2',
'Mean surrogate loss: -0.1',
'Mean entropy loss: -2',
'Mean reward: 1.25',
],
};
const health: TrainingServerInfo = {
version: '1',
ready: true,
trainerRoot: '/training',
python: '/python',
tasks: ['Unitree-Go2-Flat', ...Object.keys(MOBILE_TRAINING_TASKS)],
taskMetadata: Object.entries(MOBILE_TRAINING_TASKS).map(([id, robotId]) => ({
id,
robotId,
name: id,
family: 'mobile-manipulator',
browserCompatible: true,
terrainPresets: [],
terrainParameters: {},
sensorTypes: [],
sensorParameters: {},
mapSyncScope: '',
})),
};
beforeEach(() => {
vi.restoreAllMocks();
localStorage.clear();
sessionStorage.clear();
vi.spyOn(LocalTrainingClient.prototype, 'health').mockResolvedValue(health);
vi.spyOn(LocalTrainingClient.prototype, 'presets').mockResolvedValue([]);
});
async function connect() {
fireEvent.change(screen.getByLabelText('训练服务访问令牌'), { target: { value: 'test' } });
fireEvent.click(screen.getByRole('button', { name: '连接' }));
await screen.findByRole('button', { name: '发起本地训练' });
}
it('自动选择变体、隐藏Go2配置;上传快照→创建作业→下载元数据与ONNX→主会话导入', async () => {
const snapshot = new File(['scene'], 'scene.zip');
const policy = new File(['onnx'], 'policy.onnx');
const bridge = {
robotId: 'lekiwi-bundle',
prepare: vi.fn().mockResolvedValue(snapshot),
importPolicy: vi.fn().mockResolvedValue(undefined),
};
const upload = vi
.spyOn(LocalTrainingClient.prototype, 'uploadMobileScene')
.mockResolvedValue({ id: 'b'.repeat(64), robotId: 'lekiwi-bundle', sceneSha256: 's' });
const start = vi.spyOn(LocalTrainingClient.prototype, 'startJob').mockResolvedValue(complete);
vi.spyOn(LocalTrainingClient.prototype, 'downloadPolicy').mockResolvedValue(policy);
vi.spyOn(LocalTrainingClient.prototype, 'downloadMobileDeployment').mockResolvedValue(deployment);
const go2 = vi.fn();
render(<LocalTrainingPanel mobileTraining={bridge} onPolicyReady={go2} />);
await connect();
expect(screen.getByLabelText('训练任务')).toHaveValue(taskId);
expect(screen.queryByLabelText('训练地形')).not.toBeInTheDocument();
expect(screen.queryByLabelText('基础策略')).not.toBeInTheDocument();
expect(screen.queryByLabelText('W&B 模式')).not.toBeInTheDocument();
expect(screen.getByLabelText('并行环境')).toHaveAttribute('max', '64');
expect(screen.getByLabelText('计算设备')).toHaveValue('cpu');
fireEvent.change(screen.getByLabelText('每环境采样步数'), { target: { value: '8' } });
fireEvent.change(screen.getByLabelText('训练迭代'), { target: { value: '2' } });
fireEvent.change(screen.getByLabelText('目标 X'), { target: { value: '0.7' } });
fireEvent.click(screen.getByRole('button', { name: '发起本地训练' }));
await waitFor(() => expect(start).toHaveBeenCalledOnce());
expect(bridge.prepare).toHaveBeenCalledWith(taskId);
expect(upload).toHaveBeenCalledWith(snapshot);
expect(start.mock.calls[0][0]).toMatchObject({
taskId,
numEnvs: 1,
maxIterations: 2,
device: 'cpu',
seed: 42,
mobilePackageId: 'b'.repeat(64),
mobileParams: {
rolloutSteps: 8,
goalPosition: [0.7, 0.15, 0.019],
stage: 'navigate',
evaluationEpisodes: 10,
positionJitter: 0.1,
navigationBootstrapSteps: 4096,
},
});
expect(start.mock.calls[0][0]).not.toHaveProperty('terrainPreset');
expect(await screen.findByText('价值损失')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '导入策略' }));
await waitFor(() => expect(bridge.importPolicy).toHaveBeenCalledWith(policy, deployment));
expect(go2).not.toHaveBeenCalled();
expect(screen.getByText('60.0% / 10 回合')).toBeVisible();
expect(screen.getByText('1.100 rad/s')).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '接续此作业(保留权重)' }));
expect(screen.getByLabelText('接续作业 ID')).toHaveValue(complete.id);
fireEvent.change(screen.getByLabelText('移动操作训练阶段'), { target: { value: 'reach' } });
start.mockRejectedValueOnce(new Error('上一阶段尚未达标'));
fireEvent.click(screen.getByRole('button', { name: '发起本地训练' }));
expect(await screen.findByRole('alert')).toHaveTextContent('上一阶段尚未达标');
expect(start.mock.calls[1][0].mobileParams).toMatchObject({
stage: 'reach',
sourceJobId: complete.id,
});
});
it('场景准备失败不创建作业;任务切换恢复Go2参数', async () => {
const start = vi.spyOn(LocalTrainingClient.prototype, 'startJob');
render(
<LocalTrainingPanel
onPolicyReady={vi.fn()}
mobileTraining={{
robotId: 'lekiwi-v1',
prepare: vi.fn().mockRejectedValue(new Error('机器人绑定失败')),
importPolicy: vi.fn(),
}}
/>,
);
await connect();
fireEvent.click(screen.getByRole('button', { name: '发起本地训练' }));
expect(await screen.findByRole('alert')).toHaveTextContent('机器人绑定失败');
expect(start).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText('训练任务'), { target: { value: 'Unitree-Go2-Flat' } });
expect(screen.queryByLabelText('每环境采样步数')).not.toBeInTheDocument();
expect(screen.getByLabelText('并行环境')).toHaveValue(4096);
expect(screen.getByLabelText('计算设备')).toHaveValue('gpu');
});
it('轮询移动作业日志和完成状态;拒绝其他任务元数据', async () => {
const bridge = { robotId: 'lekiwi-bundle', prepare: vi.fn(), importPolicy: vi.fn() };
vi.spyOn(LocalTrainingClient.prototype, 'health').mockResolvedValue({
...health,
activeJobId: complete.id,
});
vi.spyOn(LocalTrainingClient.prototype, 'job')
.mockResolvedValueOnce({ ...complete, state: 'running', artifactReady: false })
.mockResolvedValue(complete);
vi.spyOn(LocalTrainingClient.prototype, 'downloadMobileDeployment').mockResolvedValue({
...deployment,
trainingTaskId: 'MobileManipulator-LeKiwi-v1',
});
render(<LocalTrainingPanel mobileTraining={bridge} onPolicyReady={vi.fn()} />);
fireEvent.change(screen.getByLabelText('训练服务访问令牌'), { target: { value: 'test' } });
fireEvent.click(screen.getByRole('button', { name: '连接' }));
await screen.findByRole('button', { name: '停止训练' });
// Trigger the existing 1500ms status polling path without changing production timers.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1600));
});
fireEvent.click(await screen.findByRole('button', { name: '导入策略' }));
expect(await screen.findByRole('alert')).toHaveTextContent('成果物与机器人变体不匹配');
expect(bridge.importPolicy).not.toHaveBeenCalled();
});
+293 -81
View File
@@ -1,3 +1,11 @@
import {
isMobileTrainingTask,
MOBILE_TRAINING_TASKS,
type MobileTrainingBridge,
type MobileDeployment,
} from '../mobile/training';
import { MOBILE_TASK } from '../mobile/RobotDescriptor';
import type { TrainingStage } from '../mobile/TaskKernel';
import { PretrainedIdentity, PretrainedSourceSelect } from './PretrainedSourceSelect';
import { pretrainedSelectionError } from './pretrainedSelection';
import { TrainingMetricsPanel } from './TrainingMetricsPanel';
@@ -60,7 +68,9 @@ export function LocalTrainingPanel({
sceneMaps = [],
sceneDirty = false,
onStatusChange,
mobileTraining,
}: {
mobileTraining?: MobileTrainingBridge;
onPolicyReady(file: File, deployment?: PolicyDeployment): void | Promise<void>;
compileScene?: TrainingSceneCompiler;
sceneMaps?: readonly PlacedMapAsset[];
@@ -100,14 +110,31 @@ export function LocalTrainingPanel({
'single_ring_raycast',
);
const [sensorCfg, setSensorCfg] = useState<Record<string, number>>({});
const mobile = isMobileTrainingTask(taskId);
const [rolloutSteps, setRolloutSteps] = useState(128);
const [mobileStage, setMobileStage] = useState<TrainingStage>('navigate');
const [sourceJobId, setSourceJobId] = useState('');
const [positionJitter, setPositionJitter] = useState(0.1);
const [evaluationEpisodes, setEvaluationEpisodes] = useState(10);
const [navigationBootstrapSteps, setNavigationBootstrapSteps] = useState(4096);
const mobileDeployment =
job && isMobileTrainingTask(job.taskId)
? (job.deployment as MobileDeployment | undefined)
: undefined;
const [objectPosition, setObjectPosition] = useState<number[]>([...MOBILE_TASK.objectStart]);
const [goalPosition, setGoalPosition] = useState<number[]>([...MOBILE_TASK.goalStart]);
const metadata = server?.taskMetadata?.find((item) => item.id === taskId);
const sourceSelectionError = pretrainedSelectionError(
server?.pretrainedSources,
taskId,
pretrainedSourceId,
);
const sourceSelectionError = mobile
? undefined
: pretrainedSelectionError(server?.pretrainedSources, taskId, pretrainedSourceId);
const selectTask = (id: string) => {
setTaskId(id);
setMobileStage('navigate');
setSourceJobId('');
if (isMobileTrainingTask(id)) setPretrainedSourceId('');
setNumEnvs(isMobileTrainingTask(id) ? 1 : 4096);
setMaxIterations(isMobileTrainingTask(id) ? 1000 : 2000);
setDevice(isMobileTrainingTask(id) ? 'cpu' : 'gpu');
setCustomTerrainBoxes(undefined);
setRewardPresetId('');
setTerrainParams({});
@@ -115,6 +142,16 @@ export function LocalTrainingPanel({
setSensorMode('single_ring_raycast');
setTerrainPreset(id === OBSTACLE_TASK_ID ? 'discrete_obstacles' : '');
};
const robotId = mobileTraining?.robotId;
const [observedRobotId, setObservedRobotId] = useState<string>();
// Reset editable task defaults only when the loaded robot changes, not on every snapshot.
if (robotId !== observedRobotId) {
setObservedRobotId(robotId);
const id =
Object.keys(MOBILE_TRAINING_TASKS).find((key) => MOBILE_TRAINING_TASKS[key] === robotId) ??
'Unitree-Go2-Flat';
if (id !== taskId) selectTask(id);
}
const syncMap = () => {
try {
if (sceneDirty) throw new Error('请先应用地图草稿,再同步训练地图');
@@ -136,6 +173,7 @@ export function LocalTrainingPanel({
setConnectionRevision(connectionEpoch.current);
setServer(undefined);
setJob(undefined);
setSourceJobId('');
setPresets([]);
setRewardPresetId('');
setError(undefined);
@@ -401,7 +439,17 @@ export function LocalTrainingPanel({
setCustomTerrainBoxes(resolvedCustomTerrain);
setTerrainParams(resolvedTerrainParams);
}
const next = await new LocalTrainingClient(endpoint, token).start({
const client = new LocalTrainingClient(endpoint, token);
let mobilePackageId: string | undefined;
if (mobile) {
if (!mobileTraining) throw new Error('请在主工作台加载对应 LeKiwi 模型');
const snapshot = await mobileTraining.prepare(taskId);
const uploaded = await client.uploadMobileScene(snapshot);
if (uploaded.robotId !== MOBILE_TRAINING_TASKS[taskId])
throw new Error('场景变体与任务不匹配');
mobilePackageId = uploaded.id;
}
const next = await client.start({
taskId,
numEnvs,
maxIterations,
@@ -409,7 +457,22 @@ export function LocalTrainingPanel({
runName,
device,
gpuIds: ids,
wandbMode,
wandbMode: mobile ? 'disabled' : wandbMode,
...(mobile
? {
mobilePackageId,
mobileParams: {
rolloutSteps,
objectPosition,
goalPosition,
stage: mobileStage,
positionJitter,
evaluationEpisodes,
navigationBootstrapSteps,
...(sourceJobId ? { sourceJobId } : {}),
},
}
: {}),
rewardPresetId: taskId === 'Unitree-Go2-Flat' ? rewardPresetId || undefined : undefined,
...(pretrainedSourceId ? { pretrainedSourceId } : {}),
...(terrainPreset ? { terrainPreset, terrainParams: resolvedTerrainParams } : {}),
@@ -447,6 +510,18 @@ export function LocalTrainingPanel({
setBusy(true);
setError(undefined);
try {
if (isMobileTrainingTask(job.taskId)) {
if (!mobileTraining) throw new Error('请在主工作台导入移动操作策略');
const client = new LocalTrainingClient(endpoint, token);
const deployment = await client.downloadMobileDeployment(job.id);
if (
deployment.trainingTaskId !== job.taskId ||
deployment.robotId !== MOBILE_TRAINING_TASKS[job.taskId]
)
throw new Error('任务成果物与机器人变体不匹配');
await mobileTraining.importPolicy(await client.downloadPolicy(job.id), deployment);
return;
}
if (job.taskId !== 'Unitree-Go2-Flat' && !job.deployment)
throw new Error('该任务缺少浏览器部署契约');
const deployment = job.deployment ? validatePolicyDeployment(job.deployment) : undefined;
@@ -532,13 +607,15 @@ export function LocalTrainingPanel({
</p>
</details>
)}
<Button
className="mt-2 w-full"
icon={<ExternalLink className="h-3.5 w-3.5" />}
onClick={openTuningDashboard}
>
打开自调参 Agent 工作台
</Button>
{!mobile && (
<Button
className="mt-2 w-full"
icon={<ExternalLink className="h-3.5 w-3.5" />}
onClick={openTuningDashboard}
>
打开自调参 Agent 工作台
</Button>
)}
{server?.ready && !job && (
<fieldset disabled={busy} className="mt-3 space-y-2">
<Field label="训练任务">
@@ -555,34 +632,37 @@ export function LocalTrainingPanel({
))}
</Select>
</Field>
<PretrainedSourceSelect
sources={server.pretrainedSources}
taskId={taskId}
value={pretrainedSourceId}
onChange={setPretrainedSourceId}
disabled={busy || uploading}
upload={{
endpoint,
token,
revision: connectionRevision,
enabled: Boolean(server.pretrainedUpload?.enabled),
onBusyChange: setUploading,
onUploaded: (source) => {
setServer(
(current) =>
current && {
...current,
pretrainedSources: [
...(current.pretrainedSources ?? []).filter((s) => s.id !== source.id),
source,
],
},
);
setPretrainedSourceId(source.id);
},
}}
/>
{metadata && (
{metadata?.ready === false && <p role="alert">{metadata.error}</p>}
{!mobile && (
<PretrainedSourceSelect
sources={server.pretrainedSources}
taskId={taskId}
value={pretrainedSourceId}
onChange={setPretrainedSourceId}
disabled={busy || uploading}
upload={{
endpoint,
token,
revision: connectionRevision,
enabled: Boolean(server.pretrainedUpload?.enabled),
onBusyChange: setUploading,
onUploaded: (source) => {
setServer(
(current) =>
current && {
...current,
pretrainedSources: [
...(current.pretrainedSources ?? []).filter((s) => s.id !== source.id),
source,
],
},
);
setPretrainedSourceId(source.id);
},
}}
/>
)}
{metadata && !mobile && (
<>
<Field label="训练地形">
<Select
@@ -693,12 +773,103 @@ export function LocalTrainingPanel({
)}
</>
)}
{mobile && (
<>
<p className="text-xs text-text-secondary">
变体:{MOBILE_TRAINING_TASKS[taskId]} · 控制步长 {MOBILE_TASK.controlDt}s ·{' '}
{MOBILE_TASK.observationSize} → 12。原生 CPU 物理,设备选项控制 PPO
网络。场景自动同步,无需下载训练包。
</p>
<Field label="移动操作训练阶段">
<Select
aria-label="移动操作训练阶段"
value={mobileStage}
onChange={(e) => setMobileStage(e.target.value as TrainingStage)}
>
<option value="navigate">1 · 底盘接近(机械臂保持)</option>
<option value="reach">2 · 末端接近(先导航,再伸臂)</option>
<option value="pick-place">3 · 抓取放置</option>
</Select>
</Field>
<Field label="接续作业 ID(留空从头训练导航)">
<input
aria-label="接续作业 ID"
className="field h-7 w-full px-2 text-xs"
value={sourceJobId}
onChange={(e) => setSourceJobId(e.target.value.trim())}
/>
</Field>
<p className="text-xs text-warning">
导航接近位为物体前方 0.3 m、朝向世界 +X,并非放置目标点。机械臂目标限速{' '}
{MOBILE_TASK.armSpeedLimit} rad/s,实测超速 {MOBILE_TASK.jointSpeedStop} rad/s
安全终止。升级阶段需前一阶段至少 10 回合评估、成功率 ≥80%、无安全终止。
</p>
<NumberField
label="位置随机范围 m"
value={positionJitter}
min={0}
max={0.3}
step={0.01}
onChange={setPositionJitter}
/>
<NumberField
label="独立评估回合"
value={evaluationEpisodes}
min={2}
max={64}
onChange={setEvaluationEpisodes}
/>
<NumberField
label="导航启动示教步数(仅初训)"
value={navigationBootstrapSteps}
min={0}
max={10000}
onChange={setNavigationBootstrapSteps}
/>
<p className="text-xs text-text-secondary">
初训可先模仿闭环底盘控制器,再用 PPO 微调;0 表示纯
PPO。导出只包含训练后的神经网络,不包含示教控制器。
</p>
<NumberField
label="每环境采样步数"
value={rolloutSteps}
min={8}
max={4096}
onChange={setRolloutSteps}
/>
{(
[
['物体', objectPosition, setObjectPosition],
['目标', goalPosition, setGoalPosition],
] as const
).map(([label, position, setPosition]) => (
<div className="grid grid-cols-3 gap-2" key={label}>
{['X', 'Y', 'Z'].map((axis, i) => (
<NumberField
key={axis}
label={`${label} ${axis}`}
value={position[i]}
min={i === 2 ? MOBILE_TASK.objectStart[2] : -MOBILE_TASK.positionScale}
max={MOBILE_TASK.positionScale}
step={0.01}
onChange={(value) =>
setPosition((old) => old.map((v, j) => (j === i ? value : v)))
}
/>
))}
</div>
))}
<p className="text-xs">
总采样步数:{numEnvs * maxIterations * rolloutSteps};训练不保证学会抓取。
</p>
</>
)}
<div className="grid grid-cols-2 gap-2">
<NumberField
label="并行环境"
value={numEnvs}
min={1}
max={16384}
max={mobile ? 64 : 16384}
onChange={setNumEnvs}
/>
<NumberField
@@ -746,48 +917,52 @@ export function LocalTrainingPanel({
/>
</Field>
</div>
<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>
{!mobile && (
<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"
icon={<Play className="h-3.5 w-3.5" />}
disabled={busy || uploading || Boolean(sourceSelectionError)}
disabled={
busy || uploading || Boolean(sourceSelectionError) || metadata?.ready === false
}
onClick={() => void start()}
>
发起本地训练
@@ -813,6 +988,23 @@ export function LocalTrainingPanel({
</Badge>
</div>
<PretrainedIdentity source={job.pretrained} />
{mobileDeployment?.evaluation && (
<div className="mt-2 text-xs">
<PropertyRow label="阶段" value={mobileDeployment.trainingStage} />
<PropertyRow
label="独立评估成功率"
value={`${(mobileDeployment.evaluation.successRate * 100).toFixed(1)}% / ${mobileDeployment.evaluation.episodes} 回合`}
/>
<PropertyRow
label="实测关节峰值"
value={`${mobileDeployment.evaluation.maxJointVelocity.toFixed(3)} rad/s`}
/>
<PropertyRow label="安全终止次数" value={mobileDeployment.evaluation.safetyStops} />
<p className="text-warning">
导出成功不代表策略达标;未达标策略导入仅用于调试,请先同阶段续训。
</p>
</div>
)}
{job.taskId === 'Unitree-Go2-Rough' && (
<div>
<p className="text-xs text-warning">
@@ -849,6 +1041,23 @@ export function LocalTrainingPanel({
</pre>
</details>
)}
{mobileDeployment && job.state === 'succeeded' && (
<Button
disabled={busy}
onClick={() => {
setSourceJobId(job.id);
setMobileStage(mobileDeployment.trainingStage ?? 'navigate');
if (mobileDeployment.resetOptions) {
setObjectPosition(mobileDeployment.resetOptions.object);
setGoalPosition(mobileDeployment.resetOptions.goal);
}
setPositionJitter(mobileDeployment.trainingParams?.positionJitter ?? 0.1);
setJob(undefined);
}}
>
接续此作业(保留权重)
</Button>
)}
<div className="mt-3 grid grid-cols-2 gap-2">
{active ? (
<Button
@@ -875,8 +1084,11 @@ export function LocalTrainingPanel({
导入策略
</Button>
<Button
disabled={busy}
onClick={() => {
setJob(undefined);
setSourceJobId('');
setMobileStage('navigate');
try {
localStorage.removeItem(TRAINING_JOB_KEY);
} catch {
+9 -1
View File
@@ -1,3 +1,4 @@
import type { MobileDeployment, MobileTrainingParams } from '../mobile/training';
import type { PolicyDeployment, TrainingTerrain } from '../rl/deployment';
export interface TrainingParameter {
@@ -7,6 +8,11 @@ export interface TrainingParameter {
integer?: boolean;
}
export interface TrainingTaskMetadata {
ready?: boolean;
error?: string;
family?: string;
robotId?: string;
controlDt?: number;
id: string;
name: string;
browserCompatible: boolean;
@@ -77,6 +83,8 @@ export interface TrainingServerInfo {
}
export interface TrainingRequest {
mobilePackageId?: string;
mobileParams?: MobileTrainingParams;
customTerrainBoxes?: TrainingTerrain;
terrainPreset?: string;
terrainParams?: Record<string, number>;
@@ -96,7 +104,7 @@ export interface TrainingRequest {
export interface TrainingJob {
pretrained?: PretrainedInitialization;
deployment?: PolicyDeployment;
deployment?: PolicyDeployment | MobileDeployment;
id: string;
state: TrainingJobState;
taskId: string;