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
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:
@@ -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();
|
||||
});
|
||||
@@ -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&B</option>
|
||||
<option value="online">在线 W&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&B</option>
|
||||
<option value="online">在线 W&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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user