feat(tuning): release V0.8.2 Agent 界面重构
This commit is contained in:
@@ -121,7 +121,7 @@ npm run training-server -- \
|
||||
|
||||
服务启动时会在终端输出一个随机访问令牌;在界面中填写该令牌后连接。令牌仅保存在当前标签页的 `sessionStorage`。界面默认连接 `http://127.0.0.1:8765`,可选择服务端允许的任务、并行环境数、训练迭代、随机种子、CPU/GPU、GPU 编号和实验记录方式。W&B 默认为本地离线模式,无需登录或 API Key;也可完全禁用,只有明确选择在线模式时才会联网登录。训练期间页面轮询迭代进度与最近日志,可以停止任务;训练成功后点击“导入策略”,生成的 `policy.onnx` 会进入现有 ONNX 加载流程。普通训练还可以选择自调参产生的命名 reward preset,而不会改写仓库默认配置。
|
||||
|
||||
连接服务后点击“打开自调参 Agent 工作台”会打开独立 `tuning.html`。该页面提供自动/逐轮审批模式、目标权重与预算配置、TensorBoard scalar 筛选/平滑/缩放、trial/rung 排行、固定评估指标、Agent 决策时间线、参数 patch 修改审批、暂停/恢复/停止、最佳 preset JSON 和 ONNX 导出。新标签页 URL 不包含 token;同源 opener 会一次性交接凭据,直接打开页面时也可手工输入。DeepSeek key 始终由本地 Python 服务的 `DEEPSEEK_API_KEY` 环境变量读取,浏览器不会接触该 key。
|
||||
连接服务后点击“打开自调参 Agent 工作台”会打开独立 `tuning.html`。该页面采用 Cyber-Industrial 三栏控制台:左侧展示 Session/ASHA 晋级树和 Trial 对比选择,中间使用 uPlot 叠加多 Trial 增量收敛曲线(金线标记历史最优)及六维物理评分,右侧以可折叠因果时间线展示 rationale、expected impact、置信度和评估结果。工具栏支持自动/逐轮审批切换、一次一 Trial 的调度令牌、服务端参数范围/固定值护栏、回滚历史最优或复现任意安全 Trial;Reward Merge Patch 与回滚差异由按需加载的 Monaco Diff 审查。scalar 以 1 Hz 非重入方式增量轮询,进入固定容量环形缓冲并由 `requestAnimationFrame` 合批后调用 `uPlot.setData`,不会在每次轮询时重建图表。新标签页 URL 不包含 token;同源 opener 会一次性交接凭据,直接打开页面时也可手工输入。DeepSeek key 始终由本地 Python 服务的 `DEEPSEEK_API_KEY` 环境变量读取,浏览器不会接触该 key。
|
||||
|
||||
桥接服务只监听本机回环地址,并检查 Host、Origin 和 Bearer Token;仅接受允许列表中的任务和经过范围校验的参数,不执行前端提供的 Shell 命令;一次只运行一个训练进程。默认任务使用仓库内置的 Go2 机器人资产与环境配置,**不会自动把浏览器中临时编辑的 MJCF/URDF 作为训练环境**。自定义浏览器模型训练需要在兼容的外部训练工程中注册 task,并通过服务的 `--trainer-root` 指定该工程。服务配置、接口和安全边界见 [`../training_server/README.md`](../training_server/README.md)。
|
||||
|
||||
|
||||
@@ -62,13 +62,14 @@ describe('LocalTrainingClient', () => {
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const client = new LocalTrainingClient('http://127.0.0.1:8765', 'deep-secret');
|
||||
await client.tuningMetrics('a'.repeat(32), 'b'.repeat(32), ['Evaluation/score'], 500);
|
||||
await client.tuningMetrics('a'.repeat(32), 'b'.repeat(32), ['Evaluation/score'], 500, 120);
|
||||
await client.decideProposal('a'.repeat(32), 'c'.repeat(32), 'approve', {
|
||||
feedback: 'ok',
|
||||
patch: { weights: { pose: 1.1 }, params: {} },
|
||||
});
|
||||
expect(fetchMock.mock.calls[0][0]).toContain('/api/tuning/sessions/');
|
||||
expect(fetchMock.mock.calls[0][0]).toContain('maxPoints=500');
|
||||
expect(fetchMock.mock.calls[0][0]).toContain('afterStep=120');
|
||||
expect(fetchMock.mock.calls[0][0]).not.toContain('deep-secret');
|
||||
const approval = fetchMock.mock.calls[1][1] as RequestInit;
|
||||
expect(approval.method).toBe('POST');
|
||||
@@ -119,11 +120,25 @@ describe('LocalTrainingClient', () => {
|
||||
});
|
||||
await client.tuningSession('a'.repeat(32));
|
||||
await client.tuningAction('a'.repeat(32), 'pause');
|
||||
await client.setTuningMode('a'.repeat(32), 'approval');
|
||||
await client.setTuningConstraints('a'.repeat(32), 2, {
|
||||
'weights.pose': { kind: 'range', min: 0.5, max: 1.5 },
|
||||
});
|
||||
await client.stepTuning('a'.repeat(32));
|
||||
await client.rollbackTuning('a'.repeat(32), 'b'.repeat(32), true);
|
||||
await client.cancelTuning('a'.repeat(32));
|
||||
await client.presets();
|
||||
const policy = await client.downloadBestPolicy('a'.repeat(32));
|
||||
expect(policy.size).toBe(3);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(9);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(13);
|
||||
const modeRequest = fetchMock.mock.calls.find(([url]) => String(url).endsWith('/mode'))?.[1] as
|
||||
RequestInit | undefined;
|
||||
expect(JSON.parse(String(modeRequest?.body))).toEqual({ mode: 'approval' });
|
||||
const constraintsRequest = fetchMock.mock.calls.find(([url]) =>
|
||||
String(url).endsWith('/constraints'),
|
||||
)?.[1] as RequestInit | undefined;
|
||||
expect(constraintsRequest?.method).toBe('PUT');
|
||||
expect(JSON.parse(String(constraintsRequest?.body))).toMatchObject({ revision: 2 });
|
||||
});
|
||||
|
||||
it('拒绝非 HTTP 地址和空访问令牌', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
ParameterConstraint,
|
||||
RewardPreset,
|
||||
TuningCapability,
|
||||
TuningCreateRequest,
|
||||
@@ -101,9 +102,41 @@ export class LocalTrainingClient {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
setTuningMode(id: string, mode: TuningSession['mode']): Promise<TuningSession> {
|
||||
return this.json(`/api/tuning/sessions/${encodeURIComponent(id)}/mode`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode }),
|
||||
});
|
||||
}
|
||||
cancelTuning(id: string): Promise<TuningSession> {
|
||||
return this.json(`/api/tuning/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
}
|
||||
setTuningConstraints(
|
||||
id: string,
|
||||
revision: number,
|
||||
constraints: Record<string, ParameterConstraint>,
|
||||
): Promise<TuningSession> {
|
||||
return this.json(`/api/tuning/sessions/${encodeURIComponent(id)}/constraints`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ revision, constraints }),
|
||||
});
|
||||
}
|
||||
stepTuning(id: string): Promise<TuningSession> {
|
||||
return this.json(`/api/tuning/sessions/${encodeURIComponent(id)}/step`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ count: 1 }),
|
||||
});
|
||||
}
|
||||
rollbackTuning(id: string, trialId: string, checkpoint = false): Promise<TuningSession> {
|
||||
return this.json(`/api/tuning/sessions/${encodeURIComponent(id)}/rollback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ trialId, checkpoint }),
|
||||
});
|
||||
}
|
||||
decideProposal(
|
||||
sessionId: string,
|
||||
proposalId: string,
|
||||
@@ -127,11 +160,16 @@ export class LocalTrainingClient {
|
||||
trialId: string,
|
||||
tags: string[] = [],
|
||||
maxPoints = 1000,
|
||||
afterStep?: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TuningMetricsResponse> {
|
||||
const query = new URLSearchParams({ maxPoints: String(maxPoints) });
|
||||
if (tags.length) query.set('tags', tags.join(','));
|
||||
if (afterStep !== undefined && Number.isFinite(afterStep))
|
||||
query.set('afterStep', String(afterStep));
|
||||
return this.json(
|
||||
`/api/tuning/sessions/${encodeURIComponent(sessionId)}/trials/${encodeURIComponent(trialId)}/metrics?${query}`,
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
presets(): Promise<RewardPreset[]> {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { LocalTrainingPanel } from './LocalTrainingPanel';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
vi.unstubAllGlobals();
|
||||
@@ -101,4 +102,60 @@ describe('LocalTrainingPanel', () => {
|
||||
expect(await screen.findByRole('button', { name: '发起本地训练' })).toBeInTheDocument();
|
||||
expect(sessionStorage.getItem('mujoco-local-training-token')).toBe('new-secret-token');
|
||||
});
|
||||
|
||||
it('接收调参窗口传回的策略文件,无需主工作台重复持有令牌', async () => {
|
||||
const onPolicyReady = vi.fn();
|
||||
const reply = vi.spyOn(window, 'postMessage').mockImplementation(() => undefined);
|
||||
render(<LocalTrainingPanel onPolicyReady={onPolicyReady} />);
|
||||
const policy = new File([new Uint8Array([1, 2, 3])], 'best-policy.onnx', {
|
||||
type: 'application/octet-stream',
|
||||
});
|
||||
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
origin: window.location.origin,
|
||||
source: window,
|
||||
data: {
|
||||
type: 'mujoco-tuning-import-policy',
|
||||
sessionId: 'a'.repeat(32),
|
||||
policy,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(onPolicyReady).toHaveBeenCalledWith(policy));
|
||||
expect(reply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'mujoco-tuning-import-policy-result',
|
||||
ok: true,
|
||||
}),
|
||||
window.location.origin,
|
||||
);
|
||||
});
|
||||
|
||||
it('旧调参消息缺少主工作台令牌时显示错误而不是抛出未处理异常', async () => {
|
||||
const reply = vi.spyOn(window, 'postMessage').mockImplementation(() => undefined);
|
||||
render(<LocalTrainingPanel onPolicyReady={vi.fn()} />);
|
||||
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
origin: window.location.origin,
|
||||
source: window,
|
||||
data: {
|
||||
type: 'mujoco-tuning-import-policy',
|
||||
sessionId: 'a'.repeat(32),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('请输入训练服务访问令牌');
|
||||
expect(reply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'mujoco-tuning-import-policy-result',
|
||||
ok: false,
|
||||
error: '请输入训练服务访问令牌',
|
||||
}),
|
||||
window.location.origin,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,18 +129,44 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
|
||||
typeof event.data !== 'object'
|
||||
)
|
||||
return;
|
||||
const data = event.data as { type?: string; sessionId?: string };
|
||||
const data = event.data as { type?: string; sessionId?: string; policy?: unknown };
|
||||
const source = event.source as Window;
|
||||
if (data.type === 'mujoco-tuning-ready') {
|
||||
(event.source as Window).postMessage(
|
||||
{ type: 'mujoco-tuning-credentials', endpoint, token },
|
||||
event.origin,
|
||||
);
|
||||
source.postMessage({ type: 'mujoco-tuning-credentials', endpoint, token }, event.origin);
|
||||
}
|
||||
if (data.type === 'mujoco-tuning-import-policy' && data.sessionId) {
|
||||
void new LocalTrainingClient(endpoint, token)
|
||||
.downloadBestPolicy(data.sessionId)
|
||||
.then(onPolicyReady)
|
||||
.catch((value: unknown) => setError(errorText(value)));
|
||||
const reply = (ok: boolean, message?: string) => {
|
||||
try {
|
||||
source.postMessage(
|
||||
{
|
||||
type: 'mujoco-tuning-import-policy-result',
|
||||
sessionId: data.sessionId,
|
||||
ok,
|
||||
error: message,
|
||||
},
|
||||
event.origin,
|
||||
);
|
||||
} catch {
|
||||
/* 调参窗口可能已关闭;不影响主工作台继续导入 */
|
||||
}
|
||||
};
|
||||
void (async () => {
|
||||
try {
|
||||
const policy =
|
||||
data.policy === undefined
|
||||
? await new LocalTrainingClient(endpoint, token).downloadBestPolicy(data.sessionId!)
|
||||
: data.policy;
|
||||
if (!(policy instanceof File) || !/\.onnx$/i.test(policy.name))
|
||||
throw new Error('调参工作台返回的 ONNX 策略无效');
|
||||
if (policy.size > 64 * 1024 * 1024) throw new Error('ONNX 策略不能超过 64 MiB');
|
||||
onPolicyReady(policy);
|
||||
reply(true);
|
||||
} catch (value) {
|
||||
const message = errorText(value);
|
||||
setError(message);
|
||||
reply(false, message);
|
||||
}
|
||||
})();
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', receive);
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface TrainingJob {
|
||||
}
|
||||
|
||||
export type TuningMode = 'automatic' | 'approval';
|
||||
export type TuningRunPolicy = 'continuous' | 'step';
|
||||
export type TuningSessionState =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
@@ -94,11 +95,14 @@ export interface TuningCreateRequest {
|
||||
fallbackEnabled: boolean;
|
||||
}
|
||||
|
||||
export type TuningTrialState =
|
||||
'queued' | 'training' | 'evaluating' | 'completed' | 'interrupted' | 'failed' | 'cancelled';
|
||||
|
||||
export interface TuningTrial {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
number: number;
|
||||
state: string;
|
||||
state: TuningTrialState;
|
||||
rung: number;
|
||||
targetIterations: number;
|
||||
rewardConfig: RewardConfiguration;
|
||||
@@ -138,6 +142,18 @@ export interface TuningAuditEvent {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type ParameterConstraint =
|
||||
{ kind: 'range'; min: number; max: number } | { kind: 'fixed'; value: number };
|
||||
|
||||
export interface TuningControlState {
|
||||
runPolicy: TuningRunPolicy;
|
||||
dispatchTokens: number;
|
||||
constraintsRevision: number;
|
||||
constraints: Record<string, ParameterConstraint>;
|
||||
activeBaseTrialId?: string;
|
||||
effectiveAfterCurrent: boolean;
|
||||
}
|
||||
|
||||
export interface TuningSession {
|
||||
id: string;
|
||||
state: TuningSessionState;
|
||||
@@ -154,6 +170,8 @@ export interface TuningSession {
|
||||
trials: TuningTrial[];
|
||||
proposals: TuningProposal[];
|
||||
audit: TuningAuditEvent[];
|
||||
/** 旧服务响应可能缺失;前端会回退到 continuous + 空约束。 */
|
||||
control?: TuningControlState;
|
||||
}
|
||||
|
||||
export interface ScalarPoint {
|
||||
@@ -170,6 +188,8 @@ export interface ScalarSeries {
|
||||
export interface TuningMetricsResponse {
|
||||
trialId: string;
|
||||
series: ScalarSeries[];
|
||||
/** 本响应中最大的 step;用于下一次增量请求。 */
|
||||
nextStep?: number;
|
||||
}
|
||||
|
||||
export interface RewardPreset {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { TuningSession } from '../training/types';
|
||||
import { AgentDecisionTimeline } from './AgentDecisionTimeline';
|
||||
import { resetTuningStore, useTuningStore } from './tuningStore';
|
||||
|
||||
const objectives = {
|
||||
velocity_tracking: 0.35,
|
||||
action_smoothness: 0.2,
|
||||
posture_stability: 0.15,
|
||||
fall_avoidance: 0.15,
|
||||
foot_slip: 0.1,
|
||||
energy: 0.05,
|
||||
};
|
||||
|
||||
function fixture(): TuningSession {
|
||||
const baseConfig = { weights: { pose: 1 }, params: {} };
|
||||
const resultConfig = { weights: { pose: 1.1 }, params: {} };
|
||||
return {
|
||||
id: 'a'.repeat(32),
|
||||
state: 'paused',
|
||||
mode: 'approval',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:02:00Z',
|
||||
config: {
|
||||
taskId: 'Unitree-Go2-Flat',
|
||||
mode: 'approval',
|
||||
runName: 'timeline',
|
||||
numEnvs: 16,
|
||||
seed: 42,
|
||||
gpuIds: [0],
|
||||
trialCount: 2,
|
||||
initialIterations: 300,
|
||||
middleIterations: 900,
|
||||
finalIterations: 2000,
|
||||
evalNumEnvs: 8,
|
||||
evalSteps: 10,
|
||||
objectiveWeights: objectives,
|
||||
fallbackEnabled: false,
|
||||
rungs: [300, 900, 2000],
|
||||
promote: [2, 2, 2],
|
||||
},
|
||||
objectiveWeights: objectives,
|
||||
message: 'paused',
|
||||
bestTrialId: '2'.repeat(32),
|
||||
consecutiveNoImprove: 0,
|
||||
fallbackEnabled: false,
|
||||
trials: [
|
||||
{
|
||||
id: '1'.repeat(32),
|
||||
sessionId: 'a'.repeat(32),
|
||||
number: 0,
|
||||
state: 'completed',
|
||||
rung: 0,
|
||||
targetIterations: 300,
|
||||
rewardConfig: baseConfig,
|
||||
score: 0,
|
||||
eligible: true,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
message: 'done',
|
||||
},
|
||||
{
|
||||
id: '2'.repeat(32),
|
||||
sessionId: 'a'.repeat(32),
|
||||
number: 1,
|
||||
state: 'completed',
|
||||
rung: 0,
|
||||
targetIterations: 300,
|
||||
rewardConfig: resultConfig,
|
||||
proposalId: '3'.repeat(32),
|
||||
score: 0.12,
|
||||
eligible: true,
|
||||
evaluation: {
|
||||
metrics: { linear_velocity_rmse: 0.2 },
|
||||
score: { score: 0.12, eligible: true, components: { velocity_tracking: 0.2 } },
|
||||
},
|
||||
createdAt: '2026-01-01T00:01:00Z',
|
||||
message: 'done',
|
||||
},
|
||||
],
|
||||
proposals: [
|
||||
{
|
||||
id: '3'.repeat(32),
|
||||
sessionId: 'a'.repeat(32),
|
||||
baseTrialId: '1'.repeat(32),
|
||||
state: 'approved',
|
||||
source: 'agent',
|
||||
patch: { weights: { pose: 1.1 }, params: {} },
|
||||
rationale: '提高姿态奖励以降低躯干倾角。',
|
||||
expectedImpact: { posture_stability: '姿态误差预计下降 8%' },
|
||||
confidence: 0.82,
|
||||
createdAt: '2026-01-01T00:00:30Z',
|
||||
},
|
||||
],
|
||||
audit: [],
|
||||
control: {
|
||||
runPolicy: 'step',
|
||||
dispatchTokens: 0,
|
||||
constraintsRevision: 0,
|
||||
constraints: {},
|
||||
effectiveAfterCurrent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetTuningStore();
|
||||
vi.unstubAllGlobals();
|
||||
useTuningStore.getState().setConnection('http://127.0.0.1:8765', 'secret');
|
||||
useTuningStore.getState().applySessionSnapshot(fixture());
|
||||
});
|
||||
|
||||
describe('AgentDecisionTimeline', () => {
|
||||
it('展示 rationale、预期影响、置信度与 Proposal→Trial 因果结果', () => {
|
||||
render(<AgentDecisionTimeline />);
|
||||
expect(screen.getByText('提高姿态奖励以降低躯干倾角。')).toBeInTheDocument();
|
||||
expect(screen.getByText('姿态误差预计下降 8%')).toBeInTheDocument();
|
||||
expect(screen.getByText('82%')).toBeInTheDocument();
|
||||
expect(screen.getByText('+0.1200')).toBeInTheDocument();
|
||||
expect(screen.getByText('→ 1.1000')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('在安全边界一键把关联 Trial 设为复现基准', async () => {
|
||||
const response = fixture();
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(response), {
|
||||
status: 202,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
render(<AgentDecisionTimeline />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '一键复现该轮' }));
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
expect(String(fetchMock.mock.calls[0][0])).toMatch(/\/rollback$/);
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[0][1]?.body))).toEqual({
|
||||
trialId: '2'.repeat(32),
|
||||
checkpoint: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,478 @@
|
||||
import { lazy, Suspense, useMemo, useState } from 'react';
|
||||
import {
|
||||
Bot,
|
||||
BrainCircuit,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleDot,
|
||||
CopyCheck,
|
||||
GitCompareArrows,
|
||||
RotateCcw,
|
||||
Sparkles,
|
||||
Target,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { Badge, Button, Dialog } from '../components/ui';
|
||||
import type {
|
||||
RewardConfiguration,
|
||||
TuningProposal,
|
||||
TuningSessionState,
|
||||
TuningTrial,
|
||||
} from '../training/types';
|
||||
import {
|
||||
formatMetric,
|
||||
mergeRewardPatch,
|
||||
OBJECTIVE_META,
|
||||
PARAMETER_BY_PATH,
|
||||
rewardConfigurationDiff,
|
||||
} from './domain';
|
||||
import { useTuningStore } from './tuningStore';
|
||||
|
||||
const RewardConfigDiffEditor = lazy(() =>
|
||||
import('./RewardConfigDiffEditor').then((module) => ({ default: module.RewardConfigDiffEditor })),
|
||||
);
|
||||
|
||||
interface DecisionView {
|
||||
proposal: TuningProposal;
|
||||
base?: TuningTrial;
|
||||
result?: TuningTrial;
|
||||
}
|
||||
|
||||
function trialForProposal(
|
||||
trials: readonly TuningTrial[],
|
||||
proposalId: string,
|
||||
): TuningTrial | undefined {
|
||||
return trials
|
||||
.filter((trial) => trial.proposalId === proposalId)
|
||||
.sort((left, right) => right.rung - left.rung)[0];
|
||||
}
|
||||
|
||||
function parseEditedConfiguration(
|
||||
text: string,
|
||||
base: RewardConfiguration,
|
||||
): TuningProposal['patch'] {
|
||||
const value = JSON.parse(text) as Partial<RewardConfiguration>;
|
||||
if (!value || typeof value !== 'object' || !value.weights || !value.params)
|
||||
throw new Error('编辑结果必须是包含 weights 与 params 的完整 Reward Configuration');
|
||||
const patch: TuningProposal['patch'] = { weights: {}, params: {} };
|
||||
for (const section of ['weights', 'params'] as const) {
|
||||
const editedSection = value[section]!;
|
||||
if (Object.keys(editedSection).some((key) => !(key in base[section])))
|
||||
throw new Error(`${section} 包含服务端白名单之外的参数`);
|
||||
if (Object.keys(base[section]).some((key) => !(key in editedSection)))
|
||||
throw new Error(`${section} 不能删除参数`);
|
||||
for (const [key, previous] of Object.entries(base[section])) {
|
||||
const next = editedSection[key];
|
||||
if (typeof next !== 'number' || !Number.isFinite(next))
|
||||
throw new Error(`${section}.${key} 必须是有限数值`);
|
||||
if (next !== previous) patch[section][key] = next;
|
||||
}
|
||||
}
|
||||
const count = Object.keys(patch.weights).length + Object.keys(patch.params).length;
|
||||
if (!count) throw new Error('修改结果与基准完全相同');
|
||||
if (count > 4) throw new Error('每轮最多修改 4 个标量');
|
||||
return patch;
|
||||
}
|
||||
|
||||
function ProposalReviewDialog({
|
||||
view,
|
||||
close,
|
||||
}: {
|
||||
view: DecisionView & { base: TuningTrial };
|
||||
close(): void;
|
||||
}) {
|
||||
const baseConfig = view.base.rewardConfig;
|
||||
const candidate = mergeRewardPatch(baseConfig, view.proposal.patch);
|
||||
const [edited, setEdited] = useState(() => JSON.stringify(candidate, null, 2));
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const [problem, setProblem] = useState<string>();
|
||||
const busy = useTuningStore((state) =>
|
||||
state.busyOperations.includes(`proposal:${view.proposal.id}`),
|
||||
);
|
||||
const decide = async (action: 'approve' | 'reject') => {
|
||||
try {
|
||||
setProblem(undefined);
|
||||
const patch = action === 'approve' ? parseEditedConfiguration(edited, baseConfig) : undefined;
|
||||
await useTuningStore.getState().decideProposal(view.proposal.id, action, {
|
||||
feedback: feedback.trim() || undefined,
|
||||
patch,
|
||||
});
|
||||
if (!useTuningStore.getState().error) close();
|
||||
} catch (value) {
|
||||
setProblem(value instanceof Error ? value.message : String(value));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={close}
|
||||
title={`Reward Merge Patch 审查 · Proposal ${view.proposal.id.slice(0, 8)}`}
|
||||
className="!max-w-6xl"
|
||||
footer={
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<label className="min-w-64 flex-1 text-[9px] text-text-tertiary">
|
||||
审批反馈(拒绝时建议填写)
|
||||
<input
|
||||
aria-label="Proposal 审批反馈"
|
||||
className="field mt-1 h-8 w-full px-2 text-xs"
|
||||
value={feedback}
|
||||
onChange={(event) => setFeedback(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="danger"
|
||||
icon={<X className="h-3.5 w-3.5" />}
|
||||
disabled={busy}
|
||||
onClick={() => void decide('reject')}
|
||||
>
|
||||
拒绝并反馈
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Check className="h-3.5 w-3.5" />}
|
||||
disabled={busy}
|
||||
onClick={() => void decide('approve')}
|
||||
>
|
||||
批准编辑后的 Patch
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="mb-3 grid gap-2 md:grid-cols-[1fr_auto]">
|
||||
<p className="rounded border border-border bg-app p-2 text-[10px] leading-4 text-text-secondary">
|
||||
Monaco 左侧为不可变基准,右侧为可编辑候选。提交时自动转换成稀疏 Merge
|
||||
Patch;服务端会再次执行白名单、单轮变化率与参数护栏校验。
|
||||
</p>
|
||||
<Badge tone="accent">置信度 {Math.round(view.proposal.confidence * 100)}%</Badge>
|
||||
</div>
|
||||
{problem && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mb-2 rounded border border-danger-border bg-danger-soft p-2 text-[10px] text-danger"
|
||||
>
|
||||
{problem}
|
||||
</p>
|
||||
)}
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-[#09111e]">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="grid h-[430px] place-items-center text-xs text-text-tertiary">
|
||||
正在按需加载 Monaco JSON Diff…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<RewardConfigDiffEditor
|
||||
original={baseConfig}
|
||||
modified={candidate}
|
||||
height={430}
|
||||
readOnly={false}
|
||||
onModifiedChange={setEdited}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PatchRows({ view }: { view: DecisionView }) {
|
||||
if (!view.base)
|
||||
return (
|
||||
<pre className="overflow-auto rounded bg-input p-2 text-[9px] text-text-secondary">
|
||||
{JSON.stringify(view.proposal.patch, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
const candidate = mergeRewardPatch(view.base.rewardConfig, view.proposal.patch);
|
||||
const changes = rewardConfigurationDiff(view.base.rewardConfig, candidate);
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{changes.map((change) => {
|
||||
const definition = PARAMETER_BY_PATH.get(change.path);
|
||||
return (
|
||||
<div
|
||||
key={change.path}
|
||||
className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-2 rounded border border-border bg-input px-2 py-1.5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-[9px] font-medium text-text-secondary">
|
||||
{definition?.label ?? change.path}
|
||||
</p>
|
||||
<code className="block truncate text-[8px] text-text-tertiary">{change.path}</code>
|
||||
</div>
|
||||
<span className="font-mono text-[9px] text-text-tertiary">
|
||||
{formatMetric(change.before)}
|
||||
</span>
|
||||
<span className="rounded bg-accent/10 px-1.5 py-0.5 font-mono text-[9px] text-accent">
|
||||
→ {formatMetric(change.after)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpectedImpact({ proposal }: { proposal: TuningProposal }) {
|
||||
const values = Object.entries(proposal.expectedImpact ?? {});
|
||||
if (!values.length)
|
||||
return <p className="text-[9px] text-text-tertiary">Agent 未提供维度预测。</p>;
|
||||
return (
|
||||
<div className="grid gap-1 sm:grid-cols-2">
|
||||
{values.map(([key, value]) => {
|
||||
const label = OBJECTIVE_META.find((item) => item.key === key)?.label ?? key;
|
||||
return (
|
||||
<div key={key} className="rounded border border-border bg-app px-2 py-1.5">
|
||||
<p className="text-[8px] uppercase tracking-wider text-text-tertiary">{label}</p>
|
||||
<p className="mt-0.5 text-[9px] leading-4 text-text-secondary">{String(value)}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultSummary({ result, base }: { result?: TuningTrial; base?: TuningTrial }) {
|
||||
if (!result)
|
||||
return <p className="rounded bg-app p-2 text-[9px] text-text-tertiary">尚未生成关联 Trial。</p>;
|
||||
const delta =
|
||||
result.score !== undefined &&
|
||||
result.score !== null &&
|
||||
base?.score !== undefined &&
|
||||
base.score !== null
|
||||
? result.score - base.score
|
||||
: undefined;
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
<div className="rounded border border-border bg-app p-2">
|
||||
<p className="text-[8px] text-text-tertiary">结果</p>
|
||||
<p className="mt-1 text-[9px] text-text-secondary">
|
||||
T{result.number} · R{result.rung}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded border border-border bg-app p-2">
|
||||
<p className="text-[8px] text-text-tertiary">Score</p>
|
||||
<p className="mt-1 font-mono text-[9px] text-text-secondary">
|
||||
{formatMetric(result.score, 5)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={`rounded border p-2 ${result.eligible ? 'border-success-border bg-success-soft' : 'border-danger-border bg-danger-soft'}`}
|
||||
>
|
||||
<p className="text-[8px] text-text-tertiary">因果结果</p>
|
||||
<p
|
||||
className={`mt-1 font-mono text-[9px] ${result.eligible ? 'text-success' : 'text-danger'}`}
|
||||
>
|
||||
{delta === undefined
|
||||
? result.eligible
|
||||
? '安全门通过'
|
||||
: '安全门拒绝'
|
||||
: `${delta >= 0 ? '+' : ''}${delta.toFixed(4)}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function canReplay(state: TuningSessionState | undefined): boolean {
|
||||
return state === 'paused' || state === 'awaiting_approval';
|
||||
}
|
||||
|
||||
export function AgentDecisionTimeline() {
|
||||
const [proposalIds, trialIds, entitiesRevision, sessionState] = useTuningStore(
|
||||
useShallow(
|
||||
(state) =>
|
||||
[state.proposalIds, state.trialIds, state.entitiesRevision, state.sessionState] as const,
|
||||
),
|
||||
);
|
||||
const views = useMemo(() => {
|
||||
void entitiesRevision;
|
||||
const state = useTuningStore.getState();
|
||||
const trials = trialIds.map((id) => state.trialsById[id]).filter(Boolean);
|
||||
return proposalIds
|
||||
.map((id) => state.proposalsById[id])
|
||||
.filter(Boolean)
|
||||
.map((proposal) => ({
|
||||
proposal,
|
||||
base: proposal.baseTrialId ? state.trialsById[proposal.baseTrialId] : undefined,
|
||||
result: trialForProposal(trials, proposal.id),
|
||||
}))
|
||||
.reverse();
|
||||
}, [entitiesRevision, proposalIds, trialIds]);
|
||||
const [expandedId, setExpandedId] = useState<string | null>();
|
||||
const [reviewing, setReviewing] = useState<DecisionView>();
|
||||
const effectiveExpandedId = expandedId === undefined ? views[0]?.proposal.id : expandedId;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="flex h-full min-h-0 flex-col rounded-xl border border-border bg-surface">
|
||||
<header className="flex shrink-0 items-center justify-between border-b border-border px-3 py-2.5">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-xs font-semibold">
|
||||
<BrainCircuit className="h-4 w-4 text-accent" /> Decision Timeline
|
||||
</h2>
|
||||
<p className="mt-0.5 text-[9px] text-text-tertiary">Proposal → 参数变化 → 固定评估</p>
|
||||
</div>
|
||||
<Badge>{views.length} 轮决策</Badge>
|
||||
</header>
|
||||
<div className="min-h-56 flex-1 overflow-auto p-3 panel-scroll">
|
||||
{views.length === 0 ? (
|
||||
<div className="grid h-40 place-items-center text-center text-[10px] text-text-tertiary">
|
||||
<div>
|
||||
<Bot className="mx-auto mb-2 h-6 w-6 opacity-50" />
|
||||
基线完成后,Agent 决策会出现在这里
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ol className="space-y-0">
|
||||
{views.map((view, index) => {
|
||||
const open = effectiveExpandedId === view.proposal.id;
|
||||
const pending = view.proposal.state === 'pending';
|
||||
const contentId = `proposal-${view.proposal.id}`;
|
||||
return (
|
||||
<li
|
||||
key={view.proposal.id}
|
||||
className="relative grid grid-cols-[18px_minmax(0,1fr)] gap-2 pb-3"
|
||||
>
|
||||
<div className="relative flex justify-center">
|
||||
{index < views.length - 1 && (
|
||||
<span className="absolute bottom-[-12px] top-3 w-px bg-border" />
|
||||
)}
|
||||
<span
|
||||
className={`relative z-10 mt-1 grid h-4 w-4 place-items-center rounded-full border ${pending ? 'border-warning-border bg-warning-soft text-warning' : view.result?.eligible ? 'border-success-border bg-success-soft text-success' : 'border-border bg-app text-text-tertiary'}`}
|
||||
>
|
||||
<CircleDot className="h-2.5 w-2.5" />
|
||||
</span>
|
||||
</div>
|
||||
<article
|
||||
className={`overflow-hidden rounded-lg border ${pending ? 'border-warning-border bg-warning-soft/30' : 'border-border bg-app/60'}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={contentId}
|
||||
className="flex w-full items-center gap-2 px-2.5 py-2 text-left hover:bg-element-hover/50"
|
||||
onClick={() => setExpandedId(open ? null : view.proposal.id)}
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[10px] font-medium">
|
||||
Proposal {view.proposal.id.slice(0, 8)}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[8px] text-text-tertiary">
|
||||
{new Date(view.proposal.createdAt).toLocaleString()} ·{' '}
|
||||
{view.proposal.source}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
tone={
|
||||
pending
|
||||
? 'warning'
|
||||
: view.proposal.state === 'approved'
|
||||
? 'success'
|
||||
: 'neutral'
|
||||
}
|
||||
>
|
||||
{pending
|
||||
? '待审批'
|
||||
: view.proposal.state === 'approved'
|
||||
? '已批准'
|
||||
: '已拒绝'}
|
||||
</Badge>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
id={contentId}
|
||||
className="space-y-3 border-t border-border px-2.5 py-2.5"
|
||||
>
|
||||
<div>
|
||||
<p className="mb-1 flex items-center gap-1 text-[8px] uppercase tracking-wider text-text-tertiary">
|
||||
<Sparkles className="h-3 w-3" /> Agent rationale
|
||||
</p>
|
||||
<p className="text-[10px] leading-[1.55] text-text-secondary">
|
||||
{view.proposal.rationale}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 flex items-center gap-1 text-[8px] uppercase tracking-wider text-text-tertiary">
|
||||
<GitCompareArrows className="h-3 w-3" /> 参数因果变更
|
||||
</p>
|
||||
<PatchRows view={view} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 flex items-center gap-1 text-[8px] uppercase tracking-wider text-text-tertiary">
|
||||
<Target className="h-3 w-3" /> Expected impact
|
||||
</p>
|
||||
<ExpectedImpact proposal={view.proposal} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex justify-between text-[8px] text-text-tertiary">
|
||||
<span>模型置信度</span>
|
||||
<span className="font-mono">
|
||||
{Math.round(view.proposal.confidence * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-element-active">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent"
|
||||
style={{
|
||||
width: `${Math.max(0, Math.min(100, view.proposal.confidence * 100))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ResultSummary result={view.result} base={view.base} />
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{pending && (
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<CopyCheck className="h-3.5 w-3.5" />}
|
||||
onClick={() => setReviewing(view)}
|
||||
>
|
||||
Monaco Diff 审查
|
||||
</Button>
|
||||
)}
|
||||
{view.result?.state === 'completed' && (
|
||||
<Button
|
||||
icon={<RotateCcw className="h-3.5 w-3.5" />}
|
||||
disabled={!canReplay(sessionState)}
|
||||
title={
|
||||
canReplay(sessionState)
|
||||
? '将该轮配置设为后续调度基准'
|
||||
: '请先暂停调度'
|
||||
}
|
||||
onClick={() =>
|
||||
void useTuningStore.getState().rollbackToTrial(view.result!.id)
|
||||
}
|
||||
>
|
||||
一键复现该轮
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{reviewing?.base && (
|
||||
<ProposalReviewDialog
|
||||
key={reviewing.proposal.id}
|
||||
view={reviewing as DecisionView & { base: TuningTrial }}
|
||||
close={() => setReviewing(undefined)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MetricsComparisonBoard } from './MetricsComparisonBoard';
|
||||
import { resetTuningStore, useTuningStore } from './tuningStore';
|
||||
|
||||
const plotSpies = vi.hoisted(() => ({
|
||||
constructors: vi.fn(),
|
||||
setData: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('uplot', () => {
|
||||
class FakeUPlot {
|
||||
data: unknown[];
|
||||
over = document.createElement('div');
|
||||
scales = { x: { min: 0, max: 10 }, y: { min: 0, max: 10 } };
|
||||
series: Array<{ scale?: string }>;
|
||||
ctx = {
|
||||
save: () => undefined,
|
||||
restore: () => undefined,
|
||||
beginPath: () => undefined,
|
||||
arc: () => undefined,
|
||||
fill: () => undefined,
|
||||
stroke: () => undefined,
|
||||
};
|
||||
|
||||
constructor(options: { series: Array<{ scale?: string }> }, data: unknown[]) {
|
||||
this.data = data;
|
||||
this.series = options.series;
|
||||
plotSpies.constructors();
|
||||
}
|
||||
|
||||
setData(data: unknown[]): void {
|
||||
this.data = data;
|
||||
plotSpies.setData();
|
||||
}
|
||||
|
||||
setSize(): void {}
|
||||
setScale(): void {}
|
||||
valToPos(value: number): number {
|
||||
return value;
|
||||
}
|
||||
destroy(): void {
|
||||
plotSpies.destroy();
|
||||
}
|
||||
}
|
||||
return { default: FakeUPlot };
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTuningStore();
|
||||
plotSpies.constructors.mockClear();
|
||||
plotSpies.setData.mockClear();
|
||||
plotSpies.destroy.mockClear();
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
vi.stubGlobal('cancelAnimationFrame', () => undefined);
|
||||
const first = {
|
||||
id: 'a',
|
||||
sessionId: 's',
|
||||
number: 0,
|
||||
state: 'completed' as const,
|
||||
rung: 0,
|
||||
targetIterations: 300,
|
||||
rewardConfig: { weights: {}, params: {} },
|
||||
score: 0,
|
||||
eligible: true,
|
||||
createdAt: '',
|
||||
message: '',
|
||||
};
|
||||
const best = { ...first, id: 'b', number: 1, score: 0.2 };
|
||||
useTuningStore.setState({
|
||||
visibleTrialIds: ['a', 'b'],
|
||||
bestTrialId: 'b',
|
||||
selectedTrialId: 'a',
|
||||
trialsById: { a: first, b: best },
|
||||
trialIds: ['a', 'b'],
|
||||
metricTag: 'Train/reward',
|
||||
knownMetricTags: ['Train/reward'],
|
||||
});
|
||||
useTuningStore.getState().enqueueMetricBatch({
|
||||
trialId: 'a',
|
||||
series: [{ tag: 'Train/reward', points: [{ step: 1, wallTime: 1, value: 1 }] }],
|
||||
});
|
||||
useTuningStore.getState().enqueueMetricBatch({
|
||||
trialId: 'b',
|
||||
series: [{ tag: 'Train/reward', points: [{ step: 1, wallTime: 1, value: 2 }] }],
|
||||
});
|
||||
useTuningStore.getState().flushMetricBatches();
|
||||
});
|
||||
|
||||
describe('MetricsComparisonBoard', () => {
|
||||
it('scalar 更新只调用 setData,不重建 uPlot,并在卸载时销毁', async () => {
|
||||
const view = render(<MetricsComparisonBoard />);
|
||||
expect(screen.getByRole('img', { name: /2 个 Trial/ })).toBeInTheDocument();
|
||||
await waitFor(() => expect(plotSpies.constructors).toHaveBeenCalledTimes(1));
|
||||
const previousUpdates = plotSpies.setData.mock.calls.length;
|
||||
|
||||
act(() => {
|
||||
useTuningStore.getState().enqueueMetricBatch({
|
||||
trialId: 'a',
|
||||
series: [{ tag: 'Train/reward', points: [{ step: 2, wallTime: 2, value: 3 }] }],
|
||||
});
|
||||
useTuningStore.getState().flushMetricBatches();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(plotSpies.setData.mock.calls.length).toBeGreaterThan(previousUpdates),
|
||||
);
|
||||
expect(plotSpies.constructors).toHaveBeenCalledTimes(1);
|
||||
view.unmount();
|
||||
expect(plotSpies.destroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,495 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { Activity, Crosshair, RotateCcw, TrendingUp, Trophy, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import uPlot from 'uplot';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { Badge, Button, Select } from '../components/ui';
|
||||
import type { ScalarPoint, TuningTrial } from '../training/types';
|
||||
import { formatMetric, OBJECTIVE_META } from './domain';
|
||||
import { useTuningStore } from './tuningStore';
|
||||
|
||||
const LINE_COLORS = ['#60a5fa', '#a78bfa', '#f472b6', '#2dd4bf', '#fb7185', '#94a3b8'];
|
||||
const BEST_COLOR = '#f3bd5c';
|
||||
const SELECTED_COLOR = '#38d39f';
|
||||
const PREFERRED_TAGS = [
|
||||
'Train/mean_reward',
|
||||
'Evaluation/linear_velocity_rmse',
|
||||
'Episode_Reward/track_linear_velocity',
|
||||
];
|
||||
|
||||
interface TrialCurve {
|
||||
trialId: string;
|
||||
label: string;
|
||||
points: ScalarPoint[];
|
||||
color: string;
|
||||
best: boolean;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
function smooth(values: (number | null)[], factor: number): (number | null)[] {
|
||||
if (factor <= 0) return values;
|
||||
let previous: number | undefined;
|
||||
return values.map((value) => {
|
||||
if (value === null) return null;
|
||||
previous = previous === undefined ? value : factor * previous + (1 - factor) * value;
|
||||
return previous;
|
||||
});
|
||||
}
|
||||
|
||||
function alignCurves(curves: readonly TrialCurve[], smoothing: number): uPlot.AlignedData {
|
||||
const steps = Array.from(
|
||||
new Set(curves.flatMap((curve) => curve.points.map((point) => point.step))),
|
||||
).sort((left, right) => left - right);
|
||||
const data: uPlot.AlignedData = [steps];
|
||||
for (const curve of curves) {
|
||||
const byStep = new Map(curve.points.map((point) => [point.step, point.value]));
|
||||
data.push(
|
||||
smooth(
|
||||
steps.map((step) => byStep.get(step) ?? null),
|
||||
smoothing,
|
||||
),
|
||||
);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function bestNodePlugin(bestSeriesIndex: number): uPlot.Plugin {
|
||||
return {
|
||||
hooks: {
|
||||
draw: [
|
||||
(chart) => {
|
||||
if (bestSeriesIndex < 1) return;
|
||||
const xValues = chart.data[0];
|
||||
const yValues = chart.data[bestSeriesIndex];
|
||||
let index = yValues.length - 1;
|
||||
while (index >= 0 && yValues[index] === null) index -= 1;
|
||||
if (index < 0) return;
|
||||
const xValue = xValues[index];
|
||||
const yValue = yValues[index];
|
||||
if (xValue === undefined || yValue === null || yValue === undefined) return;
|
||||
const x = chart.valToPos(xValue, 'x', true);
|
||||
const y = chart.valToPos(yValue, chart.series[bestSeriesIndex].scale ?? 'y', true);
|
||||
const context = chart.ctx;
|
||||
context.save();
|
||||
context.shadowColor = BEST_COLOR;
|
||||
context.shadowBlur = 10 * devicePixelRatio;
|
||||
context.fillStyle = BEST_COLOR;
|
||||
context.strokeStyle = '#09111e';
|
||||
context.lineWidth = 2 * devicePixelRatio;
|
||||
context.beginPath();
|
||||
context.arc(x, y, 5 * devicePixelRatio, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
context.restore();
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function MultiTrialPlot({
|
||||
curves,
|
||||
smoothing,
|
||||
tag,
|
||||
}: {
|
||||
curves: TrialCurve[];
|
||||
smoothing: number;
|
||||
tag: string;
|
||||
}) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<uPlot | null>(null);
|
||||
const data = useMemo(() => alignCurves(curves, smoothing), [curves, smoothing]);
|
||||
const dataRef = useRef(data);
|
||||
const manualZoom = useRef(false);
|
||||
const schema = curves
|
||||
.map((curve) => `${curve.trialId}:${curve.label}:${curve.color}:${curve.best}`)
|
||||
.join('|');
|
||||
|
||||
useEffect(() => {
|
||||
const element = host.current;
|
||||
if (!element || curves.length === 0) return;
|
||||
let resizeFrame = 0;
|
||||
const width = Math.max(320, Math.floor(element.getBoundingClientRect().width));
|
||||
const bestIndex = curves.findIndex((curve) => curve.best);
|
||||
const chart = new uPlot(
|
||||
{
|
||||
width,
|
||||
height: 338,
|
||||
padding: [12, 12, 0, 0],
|
||||
legend: { show: true, live: true },
|
||||
cursor: {
|
||||
drag: { x: true, y: false, setScale: true, dist: 8 },
|
||||
focus: { prox: 24 },
|
||||
points: { size: 7 },
|
||||
},
|
||||
focus: { alpha: 0.22 },
|
||||
scales: { x: { time: false } },
|
||||
axes: [
|
||||
{
|
||||
label: 'Iteration',
|
||||
stroke: '#8fa0b5',
|
||||
grid: { stroke: '#213044', width: 1 },
|
||||
ticks: { stroke: '#40566f' },
|
||||
},
|
||||
{
|
||||
stroke: '#8fa0b5',
|
||||
grid: { stroke: '#213044', width: 1 },
|
||||
ticks: { stroke: '#40566f' },
|
||||
size: 58,
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{ label: 'Iteration' },
|
||||
...curves.map((curve) => ({
|
||||
label: curve.label,
|
||||
stroke: curve.color,
|
||||
width: curve.best ? 3 : curve.selected ? 2.5 : 1.5,
|
||||
alpha: curve.best || curve.selected ? 1 : 0.6,
|
||||
spanGaps: true,
|
||||
points: { show: false },
|
||||
})),
|
||||
],
|
||||
plugins: [bestNodePlugin(bestIndex < 0 ? -1 : bestIndex + 1)],
|
||||
},
|
||||
// schema(曲线数量)变化时必须使用本次 render 的数据,不能使用要到下一
|
||||
// 个 effect 才更新的 ref,否则 uPlot 会短暂收到错误的列数。
|
||||
data,
|
||||
element,
|
||||
);
|
||||
chartRef.current = chart;
|
||||
|
||||
const markManualZoom = () => {
|
||||
manualZoom.current = true;
|
||||
};
|
||||
chart.over.addEventListener('mousedown', markManualZoom);
|
||||
const wheelZoom = (event: WheelEvent) => {
|
||||
if (!event.deltaY) return;
|
||||
event.preventDefault();
|
||||
manualZoom.current = true;
|
||||
const bounds = chart.over.getBoundingClientRect();
|
||||
if (!bounds.width) return;
|
||||
const scale = chart.scales.x;
|
||||
if (typeof scale.min !== 'number' || typeof scale.max !== 'number') return;
|
||||
const ratio = Math.min(1, Math.max(0, (event.clientX - bounds.left) / bounds.width));
|
||||
const anchor = scale.min + (scale.max - scale.min) * ratio;
|
||||
const factor = Math.min(1.8, Math.max(0.55, Math.exp(event.deltaY * 0.0015)));
|
||||
chart.setScale('x', {
|
||||
min: anchor - (anchor - scale.min) * factor,
|
||||
max: anchor + (scale.max - anchor) * factor,
|
||||
});
|
||||
};
|
||||
chart.over.addEventListener('wheel', wheelZoom, { passive: false });
|
||||
|
||||
let lastWidth = width;
|
||||
const observer = new ResizeObserver(() => {
|
||||
cancelAnimationFrame(resizeFrame);
|
||||
resizeFrame = requestAnimationFrame(() => {
|
||||
const nextWidth = Math.max(320, Math.floor(element.getBoundingClientRect().width));
|
||||
if (nextWidth !== lastWidth) {
|
||||
lastWidth = nextWidth;
|
||||
chart.setSize({ width: nextWidth, height: 338 });
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe(element);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
cancelAnimationFrame(resizeFrame);
|
||||
chart.over.removeEventListener('mousedown', markManualZoom);
|
||||
chart.over.removeEventListener('wheel', wheelZoom);
|
||||
chartRef.current = null;
|
||||
chart.destroy();
|
||||
};
|
||||
// schema 仅在曲线身份/样式变化时改变;数据更新由下方 setData effect 处理。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [schema]);
|
||||
|
||||
useEffect(() => {
|
||||
dataRef.current = data;
|
||||
const chart = chartRef.current;
|
||||
if (!chart) return;
|
||||
const frame = requestAnimationFrame(() => chart.setData(data, !manualZoom.current));
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [data]);
|
||||
|
||||
const zoom = (factor: number) => {
|
||||
const chart = chartRef.current;
|
||||
if (!chart) return;
|
||||
manualZoom.current = true;
|
||||
const scale = chart.scales.x;
|
||||
if (typeof scale.min !== 'number' || typeof scale.max !== 'number') return;
|
||||
const center = (scale.min + scale.max) / 2;
|
||||
const radius = ((scale.max - scale.min) * factor) / 2 || 1;
|
||||
chart.setScale('x', { min: center - radius, max: center + radius });
|
||||
};
|
||||
const reset = () => {
|
||||
const chart = chartRef.current;
|
||||
if (!chart) return;
|
||||
manualZoom.current = false;
|
||||
chart.setData(dataRef.current, true);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden rounded-xl border border-border bg-app/80 shadow-[inset_0_1px_0_rgb(255_255_255/0.025)]">
|
||||
<header className="flex min-h-10 flex-wrap items-center justify-between gap-2 border-b border-border px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="flex items-center gap-1.5 truncate text-xs font-semibold" title={tag}>
|
||||
<TrendingUp className="h-3.5 w-3.5 text-accent" /> {tag}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[9px] text-text-tertiary">拖拽框选 · 滚轮缩放 · hover 对齐</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="收敛曲线放大"
|
||||
className="rounded p-1.5 text-text-tertiary hover:bg-element-hover hover:text-text-primary"
|
||||
onClick={() => zoom(0.7)}
|
||||
>
|
||||
<ZoomIn className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="收敛曲线缩小"
|
||||
className="rounded p-1.5 text-text-tertiary hover:bg-element-hover hover:text-text-primary"
|
||||
onClick={() => zoom(1.4)}
|
||||
>
|
||||
<ZoomOut className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="重置收敛曲线缩放"
|
||||
className="rounded p-1.5 text-text-tertiary hover:bg-element-hover hover:text-text-primary"
|
||||
onClick={reset}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
ref={host}
|
||||
role="img"
|
||||
aria-label={`${curves.length} 个 Trial 的 ${tag} 收敛曲线,金色曲线为历史最优`}
|
||||
className="min-w-0 overflow-hidden p-1"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreBreakdown({ current, best }: { current?: TuningTrial; best?: TuningTrial }) {
|
||||
const currentComponents = current?.evaluation?.score?.components ?? {};
|
||||
const bestComponents = best?.evaluation?.score?.components ?? {};
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-surface p-3">
|
||||
<header className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold">Score Breakdown</h3>
|
||||
<p className="mt-0.5 text-[9px] text-text-tertiary">相对基线改善 · 中线为 0</p>
|
||||
</div>
|
||||
{best && (
|
||||
<Badge tone="warning">
|
||||
<Trophy className="h-3 w-3" /> T{best.number} 最优
|
||||
</Badge>
|
||||
)}
|
||||
</header>
|
||||
<div className="grid gap-x-4 gap-y-2 lg:grid-cols-2">
|
||||
{OBJECTIVE_META.map(({ key, label }) => {
|
||||
const currentValue = currentComponents[key] ?? 0;
|
||||
const bestValue = bestComponents[key] ?? 0;
|
||||
const currentWidth = Math.min(50, Math.abs(currentValue) * 50);
|
||||
const bestPosition = 50 + Math.max(-1, Math.min(1, bestValue)) * 50;
|
||||
return (
|
||||
<div key={key}>
|
||||
<div className="mb-1 flex items-center justify-between text-[9px]">
|
||||
<span className="text-text-secondary">{label}</span>
|
||||
<span
|
||||
className={currentValue < 0 ? 'font-mono text-danger' : 'font-mono text-accent'}
|
||||
>
|
||||
{currentValue >= 0 ? '+' : ''}
|
||||
{formatMetric(currentValue, 3)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative h-2 overflow-visible rounded-full bg-element-active">
|
||||
<span className="absolute inset-y-[-2px] left-1/2 w-px bg-border-strong" />
|
||||
<span
|
||||
className={`absolute top-0 h-2 rounded-full ${currentValue < 0 ? 'bg-danger' : 'bg-accent'}`}
|
||||
style={
|
||||
currentValue < 0
|
||||
? { right: '50%', width: `${currentWidth}%` }
|
||||
: { left: '50%', width: `${currentWidth}%` }
|
||||
}
|
||||
/>
|
||||
<span
|
||||
title={`最优 ${formatMetric(bestValue, 3)}`}
|
||||
className="absolute top-[-3px] h-3.5 w-0.5 rounded bg-warning"
|
||||
style={{ left: `${bestPosition}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricKpis({ current, best }: { current?: TuningTrial; best?: TuningTrial }) {
|
||||
const currentMetrics = current?.evaluation?.metrics ?? {};
|
||||
const bestMetrics = best?.evaluation?.metrics ?? {};
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 xl:grid-cols-6">
|
||||
{OBJECTIVE_META.map(({ key, shortLabel, metric }) => {
|
||||
const currentValue = currentMetrics[metric];
|
||||
const bestValue = bestMetrics[metric];
|
||||
const worse =
|
||||
currentValue !== undefined && bestValue !== undefined && currentValue > bestValue;
|
||||
return (
|
||||
<div key={key} className="rounded-lg border border-border bg-surface px-2.5 py-2">
|
||||
<p className="truncate text-[9px] uppercase tracking-wider text-text-tertiary">
|
||||
{shortLabel}
|
||||
</p>
|
||||
<p className={`mt-1 font-mono text-sm ${worse ? 'text-warning' : 'text-text-primary'}`}>
|
||||
{formatMetric(currentValue)}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate font-mono text-[8px] text-text-tertiary">
|
||||
best {formatMetric(bestValue)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricsComparisonBoard() {
|
||||
const [visibleTrialIds, bestTrialId, selectedTrialId, metricTag, smoothing] = useTuningStore(
|
||||
useShallow(
|
||||
(state) =>
|
||||
[
|
||||
state.visibleTrialIds,
|
||||
state.bestTrialId,
|
||||
state.selectedTrialId,
|
||||
state.metricTag,
|
||||
state.smoothing,
|
||||
] as const,
|
||||
),
|
||||
);
|
||||
const [knownMetricTags, metricsRevision] = useTuningStore(
|
||||
useShallow((state) => [state.knownMetricTags, state.metricsRevision] as const),
|
||||
);
|
||||
const current = useTuningStore((state) =>
|
||||
selectedTrialId ? state.trialsById[selectedTrialId] : undefined,
|
||||
);
|
||||
const best = useTuningStore((state) => (bestTrialId ? state.trialsById[bestTrialId] : undefined));
|
||||
const effectiveTag =
|
||||
metricTag ||
|
||||
PREFERRED_TAGS.find((tag) => knownMetricTags.includes(tag)) ||
|
||||
knownMetricTags[0] ||
|
||||
'';
|
||||
|
||||
useEffect(() => {
|
||||
if (!metricTag && effectiveTag) useTuningStore.getState().setMetricTag(effectiveTag);
|
||||
}, [effectiveTag, metricTag]);
|
||||
|
||||
const curves = useMemo(() => {
|
||||
void metricsRevision;
|
||||
if (!effectiveTag) return [];
|
||||
const snapshots = useTuningStore.getState().readMetricSeries(visibleTrialIds, effectiveTag);
|
||||
const ordered = [...snapshots].sort((left, right) => {
|
||||
if (left.trialId === bestTrialId) return 1;
|
||||
if (right.trialId === bestTrialId) return -1;
|
||||
return visibleTrialIds.indexOf(left.trialId) - visibleTrialIds.indexOf(right.trialId);
|
||||
});
|
||||
return ordered.map((series, index) => {
|
||||
const trial = useTuningStore.getState().trialsById[series.trialId];
|
||||
const isBest = series.trialId === bestTrialId;
|
||||
const isSelected = series.trialId === selectedTrialId;
|
||||
return {
|
||||
trialId: series.trialId,
|
||||
label: trial
|
||||
? `T${trial.number} · R${trial.rung}${isBest ? ' · BEST' : ''}`
|
||||
: series.trialId.slice(0, 6),
|
||||
points: series.points,
|
||||
best: isBest,
|
||||
selected: isSelected,
|
||||
color: isBest
|
||||
? BEST_COLOR
|
||||
: isSelected
|
||||
? SELECTED_COLOR
|
||||
: LINE_COLORS[index % LINE_COLORS.length],
|
||||
} satisfies TrialCurve;
|
||||
});
|
||||
}, [bestTrialId, effectiveTag, metricsRevision, selectedTrialId, visibleTrialIds]);
|
||||
|
||||
const ensureBestVisible = () => {
|
||||
if (bestTrialId) useTuningStore.getState().setTrialVisible(bestTrialId, true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<section className="rounded-xl border border-border bg-surface p-3">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Activity className="h-4 w-4 text-accent" /> 多 Trial 收敛流
|
||||
</h2>
|
||||
<p className="mt-1 text-[9px] text-text-tertiary">
|
||||
增量 scalar · 4096 点环形缓冲 · rAF 合批
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<label className="text-[9px] text-text-tertiary">
|
||||
<span className="mb-1 block">Scalar tag</span>
|
||||
<Select
|
||||
aria-label="收敛指标"
|
||||
className="w-64 max-w-[55vw]"
|
||||
value={effectiveTag}
|
||||
onChange={(event) => useTuningStore.getState().setMetricTag(event.target.value)}
|
||||
>
|
||||
{!effectiveTag && <option value="">等待 scalar…</option>}
|
||||
{knownMetricTags.map((tag) => (
|
||||
<option key={tag} value={tag}>
|
||||
{tag}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
<label className="w-28 text-[9px] text-text-tertiary">
|
||||
<span className="mb-1 block">平滑 {smoothing.toFixed(2)}</span>
|
||||
<input
|
||||
aria-label="曲线平滑"
|
||||
className="control-slider"
|
||||
type="range"
|
||||
min="0"
|
||||
max="0.95"
|
||||
step="0.05"
|
||||
value={smoothing}
|
||||
onChange={(event) =>
|
||||
useTuningStore.getState().setSmoothing(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{bestTrialId && !visibleTrialIds.includes(bestTrialId) && (
|
||||
<Button icon={<Trophy className="h-3.5 w-3.5" />} onClick={ensureBestVisible}>
|
||||
显示最优
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<MetricKpis current={current} best={best} />
|
||||
|
||||
{curves.length && effectiveTag ? (
|
||||
<MultiTrialPlot curves={curves} smoothing={smoothing} tag={effectiveTag} />
|
||||
) : (
|
||||
<div className="grid h-[390px] place-items-center rounded-xl border border-dashed border-border bg-app/60 text-center text-xs text-text-tertiary">
|
||||
<div>
|
||||
<Crosshair className="mx-auto mb-2 h-6 w-6 opacity-50" />
|
||||
选择 Trial 后等待增量 scalar 数据
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScoreBreakdown current={current} best={best} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { loader, DiffEditor } from '@monaco-editor/react';
|
||||
import * as monaco from 'monaco-editor/editor/editor.api';
|
||||
import 'monaco-editor/languages/features/json/register';
|
||||
import EditorWorker from 'monaco-editor/editor/editor.worker?worker';
|
||||
import JsonWorker from 'monaco-editor/language/json/json.worker?worker';
|
||||
import type { RewardConfiguration } from '../training/types';
|
||||
|
||||
type MonacoGlobal = typeof globalThis & {
|
||||
MonacoEnvironment?: { getWorker?: (_moduleId: string, label: string) => Worker };
|
||||
};
|
||||
|
||||
(globalThis as MonacoGlobal).MonacoEnvironment = {
|
||||
getWorker: (_moduleId, label) => (label === 'json' ? new JsonWorker() : new EditorWorker()),
|
||||
};
|
||||
loader.config({ monaco });
|
||||
|
||||
export function RewardConfigDiffEditor({
|
||||
original,
|
||||
modified,
|
||||
height = 360,
|
||||
readOnly = true,
|
||||
onModifiedChange,
|
||||
}: {
|
||||
original: RewardConfiguration;
|
||||
modified: RewardConfiguration;
|
||||
height?: number;
|
||||
readOnly?: boolean;
|
||||
onModifiedChange?: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<DiffEditor
|
||||
height={height}
|
||||
language="json"
|
||||
theme="vs-dark"
|
||||
original={JSON.stringify(original, null, 2)}
|
||||
modified={JSON.stringify(modified, null, 2)}
|
||||
keepCurrentOriginalModel={false}
|
||||
keepCurrentModifiedModel={false}
|
||||
onMount={(editor) => {
|
||||
const model = editor.getModifiedEditor().getModel();
|
||||
if (model && onModifiedChange) {
|
||||
onModifiedChange(model.getValue());
|
||||
model.onDidChangeContent(() => onModifiedChange(model.getValue()));
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
readOnly,
|
||||
originalEditable: false,
|
||||
renderSideBySide: true,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 11,
|
||||
lineNumbersMinChars: 3,
|
||||
folding: true,
|
||||
automaticLayout: true,
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
padding: { top: 8, bottom: 8 },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ScalarRingBuffer } from './ScalarRingBuffer';
|
||||
|
||||
const point = (step: number, value = step) => ({ step, wallTime: step / 10, value });
|
||||
|
||||
describe('ScalarRingBuffer', () => {
|
||||
it('固定容量覆盖最旧点并保持按 step 排序', () => {
|
||||
const buffer = new ScalarRingBuffer(3);
|
||||
buffer.appendMany([point(2), point(1), point(3), point(4)]);
|
||||
expect(buffer.size).toBe(3);
|
||||
expect(buffer.snapshot().map((value) => value.step)).toEqual([1, 3, 4]);
|
||||
expect(buffer.latestStep).toBe(4);
|
||||
});
|
||||
|
||||
it('按 step 去重更新且忽略非有限数据', () => {
|
||||
const buffer = new ScalarRingBuffer(4);
|
||||
expect(buffer.append(point(1, 2))).toBe(true);
|
||||
expect(buffer.append(point(1, 2))).toBe(false);
|
||||
expect(buffer.append(point(1, 3))).toBe(true);
|
||||
expect(buffer.append({ step: 2, wallTime: 0, value: Number.NaN })).toBe(false);
|
||||
expect(buffer.snapshot()).toEqual([{ step: 1, wallTime: 0.1, value: 3 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { ScalarPoint, ScalarSeries } from '../training/types';
|
||||
|
||||
/**
|
||||
* 固定容量、按 step 去重的数值环形缓冲。
|
||||
*
|
||||
* 网络轮询通常按 step 递增,但服务恢复时可能重发旧点,因此保留 step -> 物理槽位
|
||||
* 索引;覆盖最旧槽位时同步删除索引。React 不订阅实例本身,只订阅 store 中的版本号。
|
||||
*/
|
||||
export class ScalarRingBuffer {
|
||||
readonly capacity: number;
|
||||
private readonly steps: Float64Array;
|
||||
private readonly wallTimes: Float64Array;
|
||||
private readonly values: Float64Array;
|
||||
private readonly slotByStep = new Map<number, number>();
|
||||
private head = 0;
|
||||
private length = 0;
|
||||
|
||||
constructor(capacity = 4096) {
|
||||
if (!Number.isInteger(capacity) || capacity < 2)
|
||||
throw new Error('环形缓冲容量必须是大于 1 的整数');
|
||||
this.capacity = capacity;
|
||||
this.steps = new Float64Array(capacity);
|
||||
this.wallTimes = new Float64Array(capacity);
|
||||
this.values = new Float64Array(capacity);
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.length;
|
||||
}
|
||||
|
||||
get latestStep(): number | undefined {
|
||||
if (!this.length) return undefined;
|
||||
let latest = Number.NEGATIVE_INFINITY;
|
||||
for (const step of this.slotByStep.keys()) latest = Math.max(latest, step);
|
||||
return Number.isFinite(latest) ? latest : undefined;
|
||||
}
|
||||
|
||||
append(point: ScalarPoint): boolean {
|
||||
if (![point.step, point.wallTime, point.value].every(Number.isFinite)) return false;
|
||||
const existing = this.slotByStep.get(point.step);
|
||||
if (existing !== undefined) {
|
||||
if (this.wallTimes[existing] === point.wallTime && this.values[existing] === point.value)
|
||||
return false;
|
||||
this.wallTimes[existing] = point.wallTime;
|
||||
this.values[existing] = point.value;
|
||||
return true;
|
||||
}
|
||||
|
||||
let slot: number;
|
||||
if (this.length < this.capacity) {
|
||||
slot = (this.head + this.length) % this.capacity;
|
||||
this.length += 1;
|
||||
} else {
|
||||
slot = this.head;
|
||||
this.slotByStep.delete(this.steps[slot]);
|
||||
this.head = (this.head + 1) % this.capacity;
|
||||
}
|
||||
this.steps[slot] = point.step;
|
||||
this.wallTimes[slot] = point.wallTime;
|
||||
this.values[slot] = point.value;
|
||||
this.slotByStep.set(point.step, slot);
|
||||
return true;
|
||||
}
|
||||
|
||||
appendMany(points: readonly ScalarPoint[]): boolean {
|
||||
let changed = false;
|
||||
for (const point of points) changed = this.append(point) || changed;
|
||||
return changed;
|
||||
}
|
||||
|
||||
snapshot(): ScalarPoint[] {
|
||||
const points = Array.from(this.slotByStep, ([step, slot]) => ({
|
||||
step,
|
||||
wallTime: this.wallTimes[slot],
|
||||
value: this.values[slot],
|
||||
}));
|
||||
points.sort((left, right) => left.step - right.step);
|
||||
return points;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.slotByStep.clear();
|
||||
this.head = 0;
|
||||
this.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export interface BufferedMetricSeries {
|
||||
trialId: string;
|
||||
tag: string;
|
||||
buffer: ScalarRingBuffer;
|
||||
}
|
||||
|
||||
export function snapshotSeries(series: BufferedMetricSeries): ScalarSeries {
|
||||
return { tag: series.tag, points: series.buffer.snapshot() };
|
||||
}
|
||||
@@ -35,13 +35,18 @@ describe('TuningApp', () => {
|
||||
);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
render(<TuningApp />);
|
||||
const { container } = render(<TuningApp />);
|
||||
expect(container.firstElementChild).toHaveClass('h-full', 'overflow-y-auto');
|
||||
fireEvent.change(screen.getByLabelText('访问令牌(仅当前标签页)'), {
|
||||
target: { value: 'training-secret' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '连接/刷新' }));
|
||||
expect(await screen.findByText(/deepseek-v4-flash/)).toBeInTheDocument();
|
||||
expect(screen.getByText('新建 Unitree-Go2-Flat 调参 Session')).toBeInTheDocument();
|
||||
const trialCount = screen.getByLabelText('调参次数(候选配置数)');
|
||||
expect(trialCount).toHaveValue(12);
|
||||
fireEvent.change(trialCount, { target: { value: '36' } });
|
||||
expect(trialCount).toHaveValue(36);
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||
for (const call of fetchMock.mock.calls) {
|
||||
expect(String(call[0])).not.toContain('training-secret');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
import { AgentDecisionTimeline } from './AgentDecisionTimeline';
|
||||
import { MetricsComparisonBoard } from './MetricsComparisonBoard';
|
||||
import { TuningControlToolbar } from './TuningControlToolbar';
|
||||
import { TuningLeaderboard } from './TuningLeaderboard';
|
||||
import { TuningSessionRail } from './TuningSessionRail';
|
||||
import { useTuningPolling } from './useTuningPolling';
|
||||
|
||||
export function TuningConsole() {
|
||||
useTuningPolling();
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<TuningControlToolbar />
|
||||
<div className="grid grid-cols-1 xl:min-h-0 xl:flex-1 xl:grid-cols-[286px_minmax(520px,1fr)_390px]">
|
||||
<TuningSessionRail />
|
||||
<main className="h-[920px] min-w-0 overflow-auto bg-app p-3 panel-scroll xl:h-auto">
|
||||
<div className="mx-auto max-w-[1500px] space-y-3">
|
||||
<MetricsComparisonBoard />
|
||||
<TuningLeaderboard />
|
||||
</div>
|
||||
</main>
|
||||
<aside className="h-[640px] min-h-0 overflow-auto border-t border-border bg-panel p-3 panel-scroll xl:h-auto xl:border-l xl:border-t-0">
|
||||
<AgentDecisionTimeline />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { TuningControlToolbar } from './TuningControlToolbar';
|
||||
import { resetTuningStore, useTuningStore } from './tuningStore';
|
||||
|
||||
beforeEach(() => {
|
||||
resetTuningStore();
|
||||
const trial = {
|
||||
id: 'best',
|
||||
sessionId: 'session',
|
||||
number: 1,
|
||||
state: 'completed' as const,
|
||||
rung: 0,
|
||||
targetIterations: 300,
|
||||
rewardConfig: { weights: { track_linear_velocity: 1 }, params: {} },
|
||||
score: 0.1,
|
||||
eligible: true,
|
||||
createdAt: '',
|
||||
message: '',
|
||||
};
|
||||
useTuningStore.setState({
|
||||
sessionId: 'session',
|
||||
sessionState: 'paused',
|
||||
sessionMode: 'approval',
|
||||
sessionMessage: '等待工程师操作',
|
||||
currentTrialId: trial.id,
|
||||
selectedTrialId: trial.id,
|
||||
bestTrialId: trial.id,
|
||||
trialsById: { [trial.id]: trial },
|
||||
trialIds: [trial.id],
|
||||
});
|
||||
});
|
||||
|
||||
describe('TuningControlToolbar', () => {
|
||||
it('展示 FSM、单步/回滚控制并打开参数安全护栏', () => {
|
||||
render(<TuningControlToolbar />);
|
||||
expect(screen.getByText('安全暂停')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /单步 Trial/ })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: '回滚最优' })).toBeEnabled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /参数护栏/ }));
|
||||
expect(screen.getByRole('dialog', { name: '参数安全护栏 · Lock Range / Clamp' })).toBeVisible();
|
||||
const policy = screen.getByLabelText('线速度跟踪锁定策略');
|
||||
fireEvent.change(policy, { target: { value: 'fixed' } });
|
||||
expect(screen.getByLabelText('线速度跟踪固定值')).toBeDisabled();
|
||||
expect(screen.getByText(/安全边界由训练服务再次校验/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,523 @@
|
||||
import { lazy, Suspense, useMemo, useState } from 'react';
|
||||
import {
|
||||
Bot,
|
||||
ChevronRight,
|
||||
LockKeyhole,
|
||||
Pause,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Save,
|
||||
ShieldCheck,
|
||||
SkipForward,
|
||||
} from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { Badge, Button, Dialog, Select } from '../components/ui';
|
||||
import type { ParameterConstraint, RewardConfiguration } from '../training/types';
|
||||
import { ACTIVE_SESSION_STATES, PARAMETER_DEFINITIONS, STATE_META, parameterValue } from './domain';
|
||||
import { useTuningStore } from './tuningStore';
|
||||
|
||||
const RewardConfigDiffEditor = lazy(() =>
|
||||
import('./RewardConfigDiffEditor').then((module) => ({ default: module.RewardConfigDiffEditor })),
|
||||
);
|
||||
|
||||
const FSM_PHASES = ['Agent 分析', 'PPO 训练', '固定评估', '人工审批'] as const;
|
||||
type DraftMode = 'free' | 'range' | 'fixed';
|
||||
interface ConstraintDraft {
|
||||
mode: DraftMode;
|
||||
min: string;
|
||||
max: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function createDrafts(
|
||||
constraints: Record<string, ParameterConstraint>,
|
||||
current: RewardConfiguration | undefined,
|
||||
): Record<string, ConstraintDraft> {
|
||||
return Object.fromEntries(
|
||||
PARAMETER_DEFINITIONS.map((definition) => {
|
||||
const constraint = constraints[definition.path];
|
||||
const value = parameterValue(current, definition.path);
|
||||
if (constraint?.kind === 'range')
|
||||
return [
|
||||
definition.path,
|
||||
{
|
||||
mode: 'range',
|
||||
min: String(constraint.min),
|
||||
max: String(constraint.max),
|
||||
value: String(value),
|
||||
},
|
||||
];
|
||||
if (constraint?.kind === 'fixed')
|
||||
return [
|
||||
definition.path,
|
||||
{
|
||||
mode: 'fixed',
|
||||
min: String(definition.minimum),
|
||||
max: String(definition.maximum),
|
||||
value: String(constraint.value),
|
||||
},
|
||||
];
|
||||
return [
|
||||
definition.path,
|
||||
{
|
||||
mode: 'free',
|
||||
min: String(definition.minimum),
|
||||
max: String(definition.maximum),
|
||||
value: String(value),
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function serializeConstraints(
|
||||
drafts: Record<string, ConstraintDraft>,
|
||||
): Record<string, ParameterConstraint> {
|
||||
const constraints: Record<string, ParameterConstraint> = {};
|
||||
for (const definition of PARAMETER_DEFINITIONS) {
|
||||
const draft = drafts[definition.path];
|
||||
if (!draft || draft.mode === 'free') continue;
|
||||
if (draft.mode === 'fixed') {
|
||||
const value = Number(draft.value);
|
||||
if (!Number.isFinite(value) || value < definition.minimum || value > definition.maximum)
|
||||
throw new Error(
|
||||
`${definition.label}固定值必须在 ${definition.minimum}–${definition.maximum} 内`,
|
||||
);
|
||||
if (!definition.allowZero && value === 0)
|
||||
throw new Error(`${definition.label}不允许固定为 0`);
|
||||
constraints[definition.path] = { kind: 'fixed', value };
|
||||
continue;
|
||||
}
|
||||
const min = Number(draft.min);
|
||||
const max = Number(draft.max);
|
||||
if (
|
||||
!Number.isFinite(min) ||
|
||||
!Number.isFinite(max) ||
|
||||
min < definition.minimum ||
|
||||
max > definition.maximum ||
|
||||
min > max
|
||||
)
|
||||
throw new Error(
|
||||
`${definition.label}范围必须满足 ${definition.minimum} ≤ 下限 ≤ 上限 ≤ ${definition.maximum}`,
|
||||
);
|
||||
if (!definition.allowZero && min <= 0 && max >= 0)
|
||||
throw new Error(`${definition.label}的范围不能包含 0`);
|
||||
constraints[definition.path] = { kind: 'range', min, max };
|
||||
}
|
||||
return constraints;
|
||||
}
|
||||
|
||||
function FsmStrip({ state }: { state: keyof typeof STATE_META }) {
|
||||
const meta = STATE_META[state];
|
||||
const terminal = ['succeeded', 'failed', 'cancelled'].includes(state);
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1" aria-label={`Agent 状态:${meta.label}`}>
|
||||
{FSM_PHASES.map((label, index) => {
|
||||
const reached = terminal ? state === 'succeeded' : index <= meta.phase;
|
||||
const active = !terminal && index === meta.phase && state !== 'paused';
|
||||
return (
|
||||
<div key={label} className="flex min-w-0 items-center gap-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`h-1.5 w-1.5 shrink-0 rounded-full ${active ? 'animate-pulse bg-accent shadow-[0_0_9px_var(--ui-accent)]' : reached ? 'bg-accent' : 'bg-border-strong'}`}
|
||||
/>
|
||||
<span
|
||||
className={`hidden whitespace-nowrap text-[9px] 2xl:inline ${reached ? 'text-text-secondary' : 'text-text-tertiary'}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{index < FSM_PHASES.length - 1 && (
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-border-strong" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ParameterConstraintDialog({
|
||||
open,
|
||||
close,
|
||||
current,
|
||||
}: {
|
||||
open: boolean;
|
||||
close(): void;
|
||||
current?: RewardConfiguration;
|
||||
}) {
|
||||
const constraints = useTuningStore((state) => state.parameterConstraints);
|
||||
const revision = useTuningStore((state) => state.constraintsRevision);
|
||||
const effectiveAfterCurrent = useTuningStore((state) => state.constraintsEffectiveAfterCurrent);
|
||||
const busy = useTuningStore((state) => state.busyOperations.includes('constraints'));
|
||||
const [drafts, setDrafts] = useState(() => createDrafts(constraints, current));
|
||||
const [query, setQuery] = useState('');
|
||||
const [problem, setProblem] = useState<string>();
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return PARAMETER_DEFINITIONS;
|
||||
return PARAMETER_DEFINITIONS.filter(
|
||||
(definition) =>
|
||||
definition.label.toLowerCase().includes(normalized) ||
|
||||
definition.path.toLowerCase().includes(normalized),
|
||||
);
|
||||
}, [query]);
|
||||
|
||||
const update = (path: string, patch: Partial<ConstraintDraft>) =>
|
||||
setDrafts((value) => ({ ...value, [path]: { ...value[path], ...patch } }));
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
const value = serializeConstraints(drafts);
|
||||
setProblem(undefined);
|
||||
await useTuningStore.getState().saveParameterConstraints(value);
|
||||
if (!useTuningStore.getState().error) close();
|
||||
} catch (error) {
|
||||
setProblem(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={close}
|
||||
title="参数安全护栏 · Lock Range / Clamp"
|
||||
className="!max-w-5xl"
|
||||
footer={
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[9px] text-text-tertiary">
|
||||
Revision {revision} · 安全边界由训练服务再次校验,不依赖浏览器状态
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={close}>取消</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Save className="h-3.5 w-3.5" />}
|
||||
disabled={busy}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
保存护栏
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="rounded-md border border-success-border bg-success-soft px-3 py-2 text-[10px] text-success">
|
||||
<ShieldCheck className="mr-1 inline h-3.5 w-3.5" /> 固定值禁止 Agent
|
||||
修改;范围值越界将被服务端拒绝
|
||||
</div>
|
||||
<input
|
||||
aria-label="搜索可锁定参数"
|
||||
className="field h-8 w-64 px-2 text-xs"
|
||||
placeholder="搜索 reward / hyperparameter"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{effectiveAfterCurrent && (
|
||||
<p className="mb-2 rounded border border-warning-border bg-warning-soft px-2 py-1.5 text-[10px] text-warning">
|
||||
当前 Trial 已在执行;新护栏从下一次 Proposal 生效。
|
||||
</p>
|
||||
)}
|
||||
{problem && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mb-2 rounded border border-danger-border bg-danger-soft p-2 text-[10px] text-danger"
|
||||
>
|
||||
{problem}
|
||||
</p>
|
||||
)}
|
||||
<div className="max-h-[52vh] overflow-auto rounded-lg border border-border panel-scroll">
|
||||
<table className="w-full min-w-[760px] text-left text-[10px]">
|
||||
<thead className="sticky top-0 z-10 bg-surface text-text-tertiary">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">参数</th>
|
||||
<th className="px-2 py-2 font-medium">当前值</th>
|
||||
<th className="px-2 py-2 font-medium">策略</th>
|
||||
<th className="px-2 py-2 font-medium">工程下限</th>
|
||||
<th className="px-2 py-2 font-medium">工程上限 / 固定值</th>
|
||||
<th className="px-3 py-2 font-medium">系统边界</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((definition) => {
|
||||
const draft = drafts[definition.path];
|
||||
const currentValue = parameterValue(current, definition.path);
|
||||
return (
|
||||
<tr
|
||||
key={definition.path}
|
||||
className="border-t border-border hover:bg-element-hover/50"
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<p className="font-medium text-text-secondary">{definition.label}</p>
|
||||
<code className="text-[8px] text-text-tertiary">{definition.path}</code>
|
||||
</td>
|
||||
<td className="px-2 py-2 font-mono text-text-primary">{currentValue}</td>
|
||||
<td className="px-2 py-2">
|
||||
<Select
|
||||
aria-label={`${definition.label}锁定策略`}
|
||||
value={draft.mode}
|
||||
onChange={(event) =>
|
||||
update(definition.path, { mode: event.target.value as DraftMode })
|
||||
}
|
||||
>
|
||||
<option value="free">Agent 可调</option>
|
||||
<option value="range">锁定范围</option>
|
||||
<option value="fixed">固定参数</option>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
aria-label={`${definition.label}工程下限`}
|
||||
type="number"
|
||||
step="any"
|
||||
disabled={draft.mode !== 'range'}
|
||||
className="field h-7 w-28 px-2 font-mono disabled:opacity-40"
|
||||
value={draft.min}
|
||||
onChange={(event) => update(definition.path, { min: event.target.value })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
aria-label={`${definition.label}${draft.mode === 'fixed' ? '固定值' : '工程上限'}`}
|
||||
type="number"
|
||||
step="any"
|
||||
disabled={draft.mode === 'free' || draft.mode === 'fixed'}
|
||||
className="field h-7 w-28 px-2 font-mono disabled:opacity-40"
|
||||
value={draft.mode === 'fixed' ? draft.value : draft.max}
|
||||
onChange={(event) =>
|
||||
update(
|
||||
definition.path,
|
||||
draft.mode === 'fixed'
|
||||
? { value: event.target.value }
|
||||
: { max: event.target.value },
|
||||
)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-[9px] text-text-tertiary">
|
||||
[{definition.minimum}, {definition.maximum}]
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function TuningControlToolbar() {
|
||||
const [state, mode, message, bestTrialId, currentTrialId] = useTuningStore(
|
||||
useShallow(
|
||||
(value) =>
|
||||
[
|
||||
value.sessionState,
|
||||
value.sessionMode,
|
||||
value.sessionMessage,
|
||||
value.bestTrialId,
|
||||
value.currentTrialId,
|
||||
] as const,
|
||||
),
|
||||
);
|
||||
const [busyOperations, constraints, dispatchTokens, runPolicy] = useTuningStore(
|
||||
useShallow(
|
||||
(value) =>
|
||||
[
|
||||
value.busyOperations,
|
||||
value.parameterConstraints,
|
||||
value.dispatchTokens,
|
||||
value.runPolicy,
|
||||
] as const,
|
||||
),
|
||||
);
|
||||
const current = useTuningStore((value) => {
|
||||
const id = currentTrialId ?? value.selectedTrialId ?? value.trialIds.at(-1);
|
||||
return id ? value.trialsById[id] : undefined;
|
||||
});
|
||||
const best = useTuningStore((value) => (bestTrialId ? value.trialsById[bestTrialId] : undefined));
|
||||
const [constraintsOpen, setConstraintsOpen] = useState(false);
|
||||
const [rollbackOpen, setRollbackOpen] = useState(false);
|
||||
|
||||
if (!state || !mode) return null;
|
||||
const meta = STATE_META[state];
|
||||
const busy = busyOperations.length > 0;
|
||||
const resumable = state === 'paused' || state === 'interrupted';
|
||||
const pausable = ['queued', 'running', 'evaluating', 'awaiting_approval'].includes(state);
|
||||
const stepAllowed = state === 'paused' || state === 'awaiting_approval';
|
||||
const rollbackAllowed = Boolean(best) && (state === 'paused' || state === 'awaiting_approval');
|
||||
const active = ACTIVE_SESSION_STATES.has(state);
|
||||
const modeSwitchable = [
|
||||
'queued',
|
||||
'running',
|
||||
'evaluating',
|
||||
'awaiting_approval',
|
||||
'paused',
|
||||
].includes(state);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="border-b border-border bg-panel/95 px-3 py-2 shadow-[0_8px_30px_rgb(0_0_0/0.16)] backdrop-blur">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex min-w-[210px] items-center gap-2 border-r border-border pr-3">
|
||||
<div className="grid h-8 w-8 place-items-center rounded-lg border border-success-border bg-accent-soft text-accent">
|
||||
<Bot className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0" aria-live="polite">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[9px] uppercase tracking-[0.14em] text-text-tertiary">
|
||||
Agent FSM
|
||||
</span>
|
||||
<Badge tone={meta.tone}>{meta.label}</Badge>
|
||||
</div>
|
||||
<p className="mt-0.5 max-w-72 truncate text-[9px] text-text-tertiary" title={message}>
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FsmStrip state={state} />
|
||||
<div className="ml-auto flex flex-wrap items-center gap-1.5">
|
||||
<Select
|
||||
aria-label="运行时调参模式"
|
||||
value={mode}
|
||||
disabled={!modeSwitchable || busy}
|
||||
onChange={(event) =>
|
||||
void useTuningStore
|
||||
.getState()
|
||||
.setRuntimeMode(event.target.value as 'automatic' | 'approval')
|
||||
}
|
||||
>
|
||||
<option value="automatic">全自动决策</option>
|
||||
<option value="approval">逐轮审批</option>
|
||||
</Select>
|
||||
<Button
|
||||
icon={
|
||||
resumable ? <Play className="h-3.5 w-3.5" /> : <Pause className="h-3.5 w-3.5" />
|
||||
}
|
||||
disabled={busy || (!resumable && !pausable)}
|
||||
onClick={() => void useTuningStore.getState().pauseOrResume()}
|
||||
>
|
||||
{resumable ? '继续' : '暂停'}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<SkipForward className="h-3.5 w-3.5" />}
|
||||
disabled={busy || !stepAllowed}
|
||||
title={stepAllowed ? '只发放一个 Trial 调度令牌' : '请先暂停或等待 Proposal 审批'}
|
||||
onClick={() => void useTuningStore.getState().stepNextTrial()}
|
||||
>
|
||||
单步 Trial
|
||||
{runPolicy === 'step' && dispatchTokens > 0 ? ` · ${dispatchTokens}` : ''}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<RotateCcw className="h-3.5 w-3.5" />}
|
||||
disabled={busy || !rollbackAllowed}
|
||||
title={
|
||||
rollbackAllowed
|
||||
? '回滚后续调度基准到历史最优'
|
||||
: '请先暂停,且至少需要一个安全最优 Trial'
|
||||
}
|
||||
onClick={() => setRollbackOpen(true)}
|
||||
>
|
||||
回滚最优
|
||||
</Button>
|
||||
<Button
|
||||
icon={<LockKeyhole className="h-3.5 w-3.5" />}
|
||||
disabled={busy || !active}
|
||||
onClick={() => setConstraintsOpen(true)}
|
||||
>
|
||||
参数护栏
|
||||
{Object.keys(constraints).length > 0 && (
|
||||
<span className="rounded bg-accent/15 px-1 font-mono text-[9px] text-accent">
|
||||
{Object.keys(constraints).length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{constraintsOpen && (
|
||||
<ParameterConstraintDialog
|
||||
key={`${useTuningStore.getState().constraintsRevision}:${current?.id ?? 'none'}`}
|
||||
open
|
||||
close={() => setConstraintsOpen(false)}
|
||||
current={current?.rewardConfig}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={rollbackOpen}
|
||||
onClose={() => setRollbackOpen(false)}
|
||||
title="安全回滚到历史最优 Trial"
|
||||
className="!max-w-5xl"
|
||||
footer={
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[9px] text-text-tertiary">
|
||||
非破坏性操作:历史结果不变;后续 Proposal 以该不可变配置为基准
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setRollbackOpen(false)}>取消</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<RotateCcw className="h-3.5 w-3.5" />}
|
||||
disabled={!bestTrialId || busyOperations.includes('rollback')}
|
||||
onClick={() => {
|
||||
if (!bestTrialId) return;
|
||||
void useTuningStore
|
||||
.getState()
|
||||
.rollbackToTrial(bestTrialId, true)
|
||||
.then(() => setRollbackOpen(false));
|
||||
}}
|
||||
>
|
||||
确认回滚参数与 Checkpoint
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="mb-3 grid gap-2 sm:grid-cols-3">
|
||||
<div className="rounded-lg border border-border bg-app p-2.5">
|
||||
<p className="text-[9px] text-text-tertiary">当前 Trial</p>
|
||||
<p className="mt-1 font-mono text-sm">
|
||||
{current ? `T${current.number} · R${current.rung}` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-warning-border bg-warning-soft p-2.5">
|
||||
<p className="text-[9px] text-warning">目标 Best Trial</p>
|
||||
<p className="mt-1 font-mono text-sm text-warning">
|
||||
{best ? `T${best.number} · R${best.rung}` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-app p-2.5">
|
||||
<p className="text-[9px] text-text-tertiary">Best Score</p>
|
||||
<p className="mt-1 font-mono text-sm">{best?.score?.toFixed(5) ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{current && best ? (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-[#09111e]">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="grid h-[360px] place-items-center text-xs text-text-tertiary">
|
||||
正在按需加载 Monaco Diff…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<RewardConfigDiffEditor
|
||||
original={current.rewardConfig}
|
||||
modified={best.rewardConfig}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-40 place-items-center text-xs text-text-tertiary">
|
||||
尚无可回滚的最优配置
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ShieldAlert, ShieldCheck, Trophy } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { Badge } from '../components/ui';
|
||||
import { formatMetric } from './domain';
|
||||
import { useTuningStore } from './tuningStore';
|
||||
|
||||
export function TuningLeaderboard() {
|
||||
const [trialIds, entitiesRevision, bestTrialId, selectedTrialId] = useTuningStore(
|
||||
useShallow(
|
||||
(state) =>
|
||||
[state.trialIds, state.entitiesRevision, state.bestTrialId, state.selectedTrialId] as const,
|
||||
),
|
||||
);
|
||||
const ranked = trialIds
|
||||
.map((id) => useTuningStore.getState().trialsById[id])
|
||||
.filter((trial) => trial?.score !== undefined && trial.score !== null)
|
||||
.sort((left, right) => (right.score ?? -Infinity) - (left.score ?? -Infinity));
|
||||
void entitiesRevision;
|
||||
return (
|
||||
<section className="overflow-hidden rounded-xl border border-border bg-surface">
|
||||
<header className="flex items-center justify-between border-b border-border px-3 py-2.5">
|
||||
<h2 className="text-xs font-semibold">Trial Leaderboard</h2>
|
||||
<Badge>{ranked.length} 个已评估阶段</Badge>
|
||||
</header>
|
||||
<div className="max-h-64 overflow-auto panel-scroll">
|
||||
<table className="w-full min-w-[560px] text-left text-[9px]">
|
||||
<thead className="sticky top-0 bg-surface text-text-tertiary">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">Rank</th>
|
||||
<th className="px-2 py-2 font-medium">Trial / Rung</th>
|
||||
<th className="px-2 py-2 font-medium">Iterations</th>
|
||||
<th className="px-2 py-2 font-medium">Score</th>
|
||||
<th className="px-2 py-2 font-medium">安全门槛</th>
|
||||
<th className="px-3 py-2 font-medium">结束时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ranked.map((trial, index) => {
|
||||
const selected = trial.id === selectedTrialId;
|
||||
const best = trial.id === bestTrialId;
|
||||
return (
|
||||
<tr
|
||||
key={trial.id}
|
||||
className={`border-t border-border ${selected ? 'bg-accent/10' : 'hover:bg-element-hover/50'}`}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<span className="flex items-center gap-1 font-mono">
|
||||
{index + 1}
|
||||
{best && <Trophy className="h-3 w-3 text-warning" />}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium text-text-secondary hover:text-accent"
|
||||
onClick={() => useTuningStore.getState().selectTrial(trial.id)}
|
||||
>
|
||||
T{trial.number} / R{trial.rung}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-2 py-2 font-mono text-text-tertiary">
|
||||
{trial.targetIterations}
|
||||
</td>
|
||||
<td
|
||||
className={`px-2 py-2 font-mono ${best ? 'text-warning' : 'text-text-primary'}`}
|
||||
>
|
||||
{formatMetric(trial.score, 5)}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 ${trial.eligible ? 'text-success' : 'text-danger'}`}
|
||||
>
|
||||
{trial.eligible ? (
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
) : (
|
||||
<ShieldAlert className="h-3 w-3" />
|
||||
)}
|
||||
{trial.eligible ? '通过' : '拒绝晋级'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-text-tertiary">
|
||||
{trial.endedAt ? new Date(trial.endedAt).toLocaleString() : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{!ranked.length && (
|
||||
<p className="p-6 text-center text-[10px] text-text-tertiary">尚无固定协议评估结果</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { TuningCreateRequest, TuningTrial } from '../training/types';
|
||||
import { TuningSessionRail } from './TuningSessionRail';
|
||||
import { resetTuningStore, useTuningStore } from './tuningStore';
|
||||
|
||||
const objectives = {
|
||||
velocity_tracking: 0.35,
|
||||
action_smoothness: 0.2,
|
||||
posture_stability: 0.15,
|
||||
fall_avoidance: 0.15,
|
||||
foot_slip: 0.1,
|
||||
energy: 0.05,
|
||||
};
|
||||
|
||||
const config: TuningCreateRequest & { rungs: number[]; promote: number[] } = {
|
||||
taskId: 'Unitree-Go2-Flat',
|
||||
mode: 'approval',
|
||||
runName: 'rail-test',
|
||||
numEnvs: 16,
|
||||
seed: 42,
|
||||
gpuIds: [0],
|
||||
trialCount: 1,
|
||||
initialIterations: 300,
|
||||
middleIterations: 900,
|
||||
finalIterations: 2000,
|
||||
evalNumEnvs: 16,
|
||||
evalSteps: 20,
|
||||
objectiveWeights: objectives,
|
||||
fallbackEnabled: false,
|
||||
rungs: [300, 900, 2000],
|
||||
promote: [1, 0, 0],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetTuningStore();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
const trial: TuningTrial = {
|
||||
id: 'b'.repeat(32),
|
||||
sessionId: 'a'.repeat(32),
|
||||
number: 0,
|
||||
state: 'completed',
|
||||
rung: 0,
|
||||
targetIterations: 300,
|
||||
rewardConfig: { weights: { track_linear_velocity: 1 }, params: {} },
|
||||
score: 0,
|
||||
eligible: true,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
endedAt: '2026-01-01T00:01:00Z',
|
||||
message: '完成',
|
||||
};
|
||||
useTuningStore.setState({
|
||||
endpoint: 'http://127.0.0.1:8765',
|
||||
token: 'child-secret',
|
||||
sessionId: trial.sessionId,
|
||||
sessionState: 'succeeded',
|
||||
sessionMode: 'approval',
|
||||
sessionConfig: config,
|
||||
sessionMessage: '全部完成',
|
||||
bestTrialId: trial.id,
|
||||
selectedTrialId: trial.id,
|
||||
visibleTrialIds: [trial.id],
|
||||
trialIds: [trial.id],
|
||||
trialsById: { [trial.id]: trial },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'opener', { configurable: true, value: null });
|
||||
});
|
||||
|
||||
describe('TuningSessionRail', () => {
|
||||
it('在调参页下载最佳策略并把文件传回主工作台', async () => {
|
||||
const postMessage = vi.fn();
|
||||
Object.defineProperty(window, 'opener', {
|
||||
configurable: true,
|
||||
value: { closed: false, postMessage },
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(new Blob([new Uint8Array([1, 2, 3])]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
render(<TuningSessionRail />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '导入' }));
|
||||
|
||||
await waitFor(() => expect(postMessage).toHaveBeenCalledTimes(1));
|
||||
const [message, targetOrigin] = postMessage.mock.calls[0] as [
|
||||
{ type: string; sessionId: string; policy: File; token?: string },
|
||||
string,
|
||||
];
|
||||
expect(message.type).toBe('mujoco-tuning-import-policy');
|
||||
expect(message.sessionId).toBe('a'.repeat(32));
|
||||
expect(message.policy).toBeInstanceOf(File);
|
||||
expect(message.policy.name).toBe('best-policy-aaaaaaaa.onnx');
|
||||
expect(message).not.toHaveProperty('token');
|
||||
expect(targetOrigin).toBe(window.location.origin);
|
||||
expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get('Authorization')).toBe(
|
||||
'Bearer child-secret',
|
||||
);
|
||||
expect(screen.getByRole('button', { name: '已发送' })).toBeInTheDocument();
|
||||
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
origin: window.location.origin,
|
||||
data: {
|
||||
type: 'mujoco-tuning-import-policy-result',
|
||||
sessionId: 'a'.repeat(32),
|
||||
ok: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByRole('button', { name: '已导入' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,344 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
FileJson,
|
||||
GitBranch,
|
||||
Layers3,
|
||||
Square,
|
||||
Trophy,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { Badge, Button, ProgressBar } from '../components/ui';
|
||||
import { LocalTrainingClient } from '../training/LocalTrainingClient';
|
||||
import { STATE_META, TRIAL_STATE_LABELS } from './domain';
|
||||
import { useTuningStore } from './tuningStore';
|
||||
|
||||
function downloadFile(file: File): void {
|
||||
const url = URL.createObjectURL(file);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = file.name;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function AshaPromotionTree() {
|
||||
const [trialIds, entitiesRevision, config, bestTrialId] = useTuningStore(
|
||||
useShallow(
|
||||
(state) =>
|
||||
[state.trialIds, state.entitiesRevision, state.sessionConfig, state.bestTrialId] as const,
|
||||
),
|
||||
);
|
||||
const rungs = (config?.rungs ?? [300, 900, 2000]).map((iterations, rung) => {
|
||||
const trials = trialIds
|
||||
.map((id) => useTuningStore.getState().trialsById[id])
|
||||
.filter((trial) => trial?.rung === rung);
|
||||
const completed = trials.filter((trial) => trial.state === 'completed').length;
|
||||
return {
|
||||
rung,
|
||||
iterations,
|
||||
total: config?.promote?.[rung] ?? trials.length,
|
||||
completed,
|
||||
bestHere: trials.some((trial) => trial.id === bestTrialId),
|
||||
};
|
||||
});
|
||||
void entitiesRevision;
|
||||
return (
|
||||
<section className="rounded-lg border border-border bg-app/70 p-2.5">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-text-secondary">
|
||||
<GitBranch className="h-3.5 w-3.5 text-accent" /> ASHA 晋级树
|
||||
</h2>
|
||||
<span className="font-mono text-[8px] text-text-tertiary">η dynamic</span>
|
||||
</div>
|
||||
<div className="space-y-0">
|
||||
{rungs.map((rung, index) => (
|
||||
<div key={rung.rung} className="grid grid-cols-[18px_minmax(0,1fr)] gap-2">
|
||||
<div className="relative flex justify-center">
|
||||
{index < rungs.length - 1 && (
|
||||
<span className="absolute bottom-0 top-4 w-px bg-border-strong" />
|
||||
)}
|
||||
<span
|
||||
className={`relative z-10 mt-2 h-2.5 w-2.5 rounded-full border ${rung.bestHere ? 'border-warning bg-warning shadow-[0_0_8px_rgb(243_189_92/0.5)]' : rung.completed ? 'border-accent bg-accent' : 'border-border-strong bg-surface'}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2 rounded border border-border bg-surface px-2 py-1.5">
|
||||
<div className="flex items-center justify-between text-[9px]">
|
||||
<span className="font-medium">Rung {rung.rung}</span>
|
||||
<span className="font-mono text-text-tertiary">{rung.iterations} it</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between text-[8px] text-text-tertiary">
|
||||
<span>
|
||||
{rung.completed}/{rung.total || '—'} 完成
|
||||
</span>
|
||||
{rung.bestHere && <span className="text-warning">Best 所在层</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function TuningSessionRail() {
|
||||
const [policyImportState, setPolicyImportState] = useState<
|
||||
'idle' | 'downloading' | 'sent' | 'imported' | 'downloaded'
|
||||
>('idle');
|
||||
const [sessionId, state, config, message, trialIds, selectedTrialId, bestTrialId] =
|
||||
useTuningStore(
|
||||
useShallow(
|
||||
(value) =>
|
||||
[
|
||||
value.sessionId,
|
||||
value.sessionState,
|
||||
value.sessionConfig,
|
||||
value.sessionMessage,
|
||||
value.trialIds,
|
||||
value.selectedTrialId,
|
||||
value.bestTrialId,
|
||||
] as const,
|
||||
),
|
||||
);
|
||||
const [visibleTrialIds, entitiesRevision, busy, endpoint, token] = useTuningStore(
|
||||
useShallow(
|
||||
(value) =>
|
||||
[
|
||||
value.visibleTrialIds,
|
||||
value.entitiesRevision,
|
||||
value.busyOperations.length > 0,
|
||||
value.endpoint,
|
||||
value.token,
|
||||
] as const,
|
||||
),
|
||||
);
|
||||
const reportError = (value: unknown) =>
|
||||
useTuningStore.setState({ error: value instanceof Error ? value.message : String(value) });
|
||||
|
||||
useEffect(() => {
|
||||
const receive = (event: MessageEvent) => {
|
||||
if (event.origin !== location.origin || typeof event.data !== 'object') return;
|
||||
const data = event.data as {
|
||||
type?: string;
|
||||
sessionId?: string;
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
if (data.type !== 'mujoco-tuning-import-policy-result' || data.sessionId !== sessionId)
|
||||
return;
|
||||
if (data.ok) setPolicyImportState('imported');
|
||||
else {
|
||||
setPolicyImportState('idle');
|
||||
useTuningStore.setState({ error: data.error || '主工作台未能导入 ONNX 策略' });
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', receive);
|
||||
return () => window.removeEventListener('message', receive);
|
||||
}, [sessionId]);
|
||||
|
||||
if (!sessionId || !state || !config) return null;
|
||||
const trials = trialIds.map((id) => useTuningStore.getState().trialsById[id]).filter(Boolean);
|
||||
const completed = trials.filter((trial) => trial.state === 'completed').length;
|
||||
const expected =
|
||||
config.trialCount + config.promote.slice(1).reduce((sum, value) => sum + value, 0);
|
||||
const bestTrial = bestTrialId ? useTuningStore.getState().trialsById[bestTrialId] : undefined;
|
||||
|
||||
const importBestPolicy = async () => {
|
||||
setPolicyImportState('downloading');
|
||||
try {
|
||||
const policy = await new LocalTrainingClient(endpoint, token).downloadBestPolicy(sessionId);
|
||||
const opener = window.opener;
|
||||
if (opener && !opener.closed) {
|
||||
opener.postMessage(
|
||||
{ type: 'mujoco-tuning-import-policy', sessionId, policy },
|
||||
location.origin,
|
||||
);
|
||||
setPolicyImportState('sent');
|
||||
} else {
|
||||
downloadFile(policy);
|
||||
setPolicyImportState('downloaded');
|
||||
}
|
||||
} catch (value) {
|
||||
setPolicyImportState('idle');
|
||||
reportError(value);
|
||||
}
|
||||
};
|
||||
void entitiesRevision;
|
||||
|
||||
return (
|
||||
<aside className="flex h-[620px] min-h-0 flex-col border-b border-border bg-panel xl:h-auto xl:border-b-0 xl:border-r">
|
||||
<div className="shrink-0 border-b border-border p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-semibold" title={config.runName}>
|
||||
{config.runName}
|
||||
</p>
|
||||
<p
|
||||
className="mt-0.5 truncate font-mono text-[8px] text-text-tertiary"
|
||||
title={sessionId}
|
||||
>
|
||||
{sessionId}
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone={STATE_META[state].tone}>{STATE_META[state].label}</Badge>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<ProgressBar
|
||||
value={expected ? completed / expected : 0}
|
||||
label={`${completed} / ${expected} Trial 阶段`}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className="mt-2 line-clamp-2 rounded border border-border bg-app px-2 py-1.5 text-[9px] leading-4 text-text-tertiary"
|
||||
title={message}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-auto p-3 panel-scroll">
|
||||
<AshaPromotionTree />
|
||||
{bestTrial && (
|
||||
<section className="rounded-lg border border-warning-border bg-warning-soft/30 p-2.5">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-1.5 text-[10px] font-semibold text-warning">
|
||||
<Trophy className="h-3.5 w-3.5" /> Best Artifact
|
||||
</h2>
|
||||
<span className="font-mono text-[8px] text-warning">
|
||||
T{bestTrial.number} · R{bestTrial.rung}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
<Button
|
||||
icon={<Download className="h-3 w-3" />}
|
||||
onClick={() =>
|
||||
void new LocalTrainingClient(endpoint, token)
|
||||
.downloadBestPolicy(sessionId)
|
||||
.then(downloadFile)
|
||||
.catch(reportError)
|
||||
}
|
||||
>
|
||||
ONNX
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Upload className="h-3 w-3" />}
|
||||
disabled={busy || policyImportState === 'downloading'}
|
||||
onClick={() => void importBestPolicy()}
|
||||
>
|
||||
{
|
||||
{
|
||||
idle: '导入',
|
||||
downloading: '下载中',
|
||||
sent: '已发送',
|
||||
imported: '已导入',
|
||||
downloaded: '已下载',
|
||||
}[policyImportState]
|
||||
}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<FileJson className="h-3 w-3" />}
|
||||
onClick={() => {
|
||||
const file = new File(
|
||||
[JSON.stringify(bestTrial.rewardConfig, null, 2) + '\n'],
|
||||
`reward-preset-${sessionId.slice(0, 8)}.json`,
|
||||
{ type: 'application/json' },
|
||||
);
|
||||
downloadFile(file);
|
||||
}}
|
||||
>
|
||||
Preset
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<section>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-text-secondary">
|
||||
<Layers3 className="h-3.5 w-3.5 text-accent" /> Trial Runs
|
||||
</h2>
|
||||
<span className="text-[8px] text-text-tertiary">最多叠加 6 条</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{trials.map((trial) => {
|
||||
const selected = trial.id === selectedTrialId;
|
||||
const visible = visibleTrialIds.includes(trial.id);
|
||||
const best = trial.id === bestTrialId;
|
||||
return (
|
||||
<div
|
||||
key={trial.id}
|
||||
className={`group rounded-lg border transition-colors ${selected ? 'border-accent bg-accent/10' : best ? 'border-warning-border bg-warning-soft/40' : 'border-border bg-app hover:bg-element-hover/50'}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 p-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`对比 Trial ${trial.number} Rung ${trial.rung}`}
|
||||
checked={visible}
|
||||
onChange={(event) =>
|
||||
useTuningStore.getState().setTrialVisible(trial.id, event.target.checked)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 text-left"
|
||||
onClick={() => useTuningStore.getState().selectTrial(trial.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-1 text-[10px] font-medium">
|
||||
T{trial.number}{' '}
|
||||
<span className="text-text-tertiary">/ R{trial.rung}</span>
|
||||
{best && (
|
||||
<Trophy className="h-3 w-3 text-warning" aria-label="历史最优" />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[8px] text-text-tertiary">
|
||||
{TRIAL_STATE_LABELS[trial.state] ?? trial.state}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between font-mono text-[8px] text-text-tertiary">
|
||||
<span>{trial.targetIterations} it</span>
|
||||
<span className={best ? 'text-warning' : ''}>
|
||||
{trial.score === undefined || trial.score === null
|
||||
? 'score —'
|
||||
: trial.score.toFixed(5)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="grid shrink-0 grid-cols-2 gap-1.5 border-t border-border p-3">
|
||||
<Button
|
||||
icon={<ArrowLeft className="h-3.5 w-3.5" />}
|
||||
onClick={() => useTuningStore.getState().leaveSession()}
|
||||
>
|
||||
会话列表
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
icon={<Square className="h-3 w-3" />}
|
||||
disabled={
|
||||
busy ||
|
||||
![
|
||||
'queued',
|
||||
'running',
|
||||
'evaluating',
|
||||
'awaiting_approval',
|
||||
'paused',
|
||||
'interrupted',
|
||||
].includes(state)
|
||||
}
|
||||
onClick={() => void useTuningStore.getState().cancelSession()}
|
||||
>
|
||||
停止 Session
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import type {
|
||||
ObjectiveWeights,
|
||||
RewardConfiguration,
|
||||
TuningSessionState,
|
||||
TuningTrial,
|
||||
} from '../training/types';
|
||||
|
||||
export type RewardSection = keyof RewardConfiguration;
|
||||
|
||||
export interface ParameterDefinition {
|
||||
path: string;
|
||||
section: RewardSection;
|
||||
key: string;
|
||||
label: string;
|
||||
minimum: number;
|
||||
maximum: number;
|
||||
defaultValue: number;
|
||||
allowZero: boolean;
|
||||
precision: number;
|
||||
}
|
||||
|
||||
const weight = (
|
||||
key: string,
|
||||
label: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
defaultValue: number,
|
||||
allowZero = true,
|
||||
precision = 4,
|
||||
): ParameterDefinition => ({
|
||||
path: `weights.${key}`,
|
||||
section: 'weights',
|
||||
key,
|
||||
label,
|
||||
minimum,
|
||||
maximum,
|
||||
defaultValue,
|
||||
allowZero,
|
||||
precision,
|
||||
});
|
||||
|
||||
const parameter = (
|
||||
key: string,
|
||||
label: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
defaultValue: number,
|
||||
precision = 4,
|
||||
): ParameterDefinition => ({
|
||||
path: `params.${key}`,
|
||||
section: 'params',
|
||||
key,
|
||||
label,
|
||||
minimum,
|
||||
maximum,
|
||||
defaultValue,
|
||||
allowZero: false,
|
||||
precision,
|
||||
});
|
||||
|
||||
/** 与 training_server/tuning/schema.py 同步的只读前端目录;服务端仍是安全边界。 */
|
||||
export const PARAMETER_DEFINITIONS: readonly ParameterDefinition[] = [
|
||||
weight('track_linear_velocity', '线速度跟踪', 0.5, 3, 1, false),
|
||||
weight('track_angular_velocity', '角速度跟踪', 0.25, 2, 1, false),
|
||||
weight('body_orientation_l2', '躯干姿态', -3, -0.1, -1, false),
|
||||
weight('pose', '目标姿态', 0, 2.5, 1),
|
||||
weight('body_ang_vel', '机身角速度', -0.2, 0, -0.05),
|
||||
weight('angular_momentum', '角动量', -0.1, 0, -0.025),
|
||||
weight('is_terminated', '跌倒终止', -400, -50, -200, false, 2),
|
||||
weight('joint_acc_l2', '关节加速度', -2e-6, 0, -2.5e-7, true, 8),
|
||||
weight('joint_pos_limits', '关节限位', -30, -2, -10, false, 2),
|
||||
weight('action_rate_l2', '动作平滑', -0.2, -0.005, -0.05),
|
||||
weight('foot_gait', '步态相位', 0, 1.5, 0.5),
|
||||
weight('foot_clearance', '抬脚高度', -3, 0, -1),
|
||||
weight('foot_slip', '足端滑移', -1, 0, -0.25),
|
||||
weight('soft_landing', '柔和落足', -0.005, 0, -0.001, true, 6),
|
||||
weight('stand_still', '静止姿态', -3, 0, -1),
|
||||
weight('electrical_power', '电功率', -0.005, 0, 0, true, 6),
|
||||
parameter('track_linear_velocity.std', '线速度核宽', 0.25, 1, 0.5),
|
||||
parameter('track_angular_velocity.std', '角速度核宽', 0.35, 1.2, Math.sqrt(0.5)),
|
||||
parameter('pose.std_standing_scale', '站立姿态尺度', 0.5, 2, 1),
|
||||
parameter('pose.std_walking_scale', '行走姿态尺度', 0.5, 2, 1),
|
||||
parameter('pose.std_running_scale', '奔跑姿态尺度', 0.5, 2, 1),
|
||||
parameter('pose.walking_threshold', '行走阈值', 0.05, 0.5, 0.1),
|
||||
parameter('pose.running_threshold', '奔跑阈值', 1, 2.5, 1.5),
|
||||
parameter('foot_gait.period', '步态周期', 0.4, 0.8, 0.6),
|
||||
parameter('foot_gait.threshold', '步态阈值', 0.45, 0.65, 0.56),
|
||||
parameter('foot_gait.command_threshold', '步态命令阈值', 0.02, 0.3, 0.1),
|
||||
parameter('foot_clearance.target_height', '目标抬脚高度', 0.06, 0.16, 0.1),
|
||||
parameter('foot_clearance.command_threshold', '抬脚命令阈值', 0.02, 0.3, 0.1),
|
||||
parameter('foot_slip.command_threshold', '滑移命令阈值', 0.02, 0.3, 0.1),
|
||||
parameter('soft_landing.command_threshold', '落足命令阈值', 0.02, 0.3, 0.1),
|
||||
parameter('stand_still.command_threshold', '静止命令阈值', 0.02, 0.3, 0.1),
|
||||
] as const;
|
||||
|
||||
export const PARAMETER_BY_PATH = new Map(
|
||||
PARAMETER_DEFINITIONS.map((definition) => [definition.path, definition]),
|
||||
);
|
||||
|
||||
export const OBJECTIVE_META: ReadonlyArray<{
|
||||
key: keyof ObjectiveWeights;
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
metric: string;
|
||||
}> = [
|
||||
{
|
||||
key: 'velocity_tracking',
|
||||
label: '速度跟踪',
|
||||
shortLabel: '速度',
|
||||
metric: 'linear_velocity_rmse',
|
||||
},
|
||||
{
|
||||
key: 'action_smoothness',
|
||||
label: '动作平滑度',
|
||||
shortLabel: '平滑',
|
||||
metric: 'mean_action_acc',
|
||||
},
|
||||
{
|
||||
key: 'posture_stability',
|
||||
label: '躯干姿态稳定',
|
||||
shortLabel: '姿态',
|
||||
metric: 'orientation_error',
|
||||
},
|
||||
{
|
||||
key: 'fall_avoidance',
|
||||
label: '跌倒规避',
|
||||
shortLabel: '防跌',
|
||||
metric: 'fall_rate',
|
||||
},
|
||||
{ key: 'foot_slip', label: '接触滑移抑制', shortLabel: '滑移', metric: 'slip_velocity' },
|
||||
{ key: 'energy', label: '机械能耗', shortLabel: '能耗', metric: 'mechanical_power' },
|
||||
];
|
||||
|
||||
export const STATE_META = {
|
||||
queued: { label: '分析排队', phase: 0, tone: 'neutral' },
|
||||
running: { label: '策略训练中', phase: 1, tone: 'accent' },
|
||||
evaluating: { label: '固定步态评估中', phase: 2, tone: 'warning' },
|
||||
awaiting_approval: { label: '等待人工审批', phase: 3, tone: 'warning' },
|
||||
paused: { label: '安全暂停', phase: 3, tone: 'neutral' },
|
||||
interrupted: { label: '服务已中断', phase: 3, tone: 'warning' },
|
||||
succeeded: { label: '调优完成', phase: 4, tone: 'success' },
|
||||
failed: { label: '调优失败', phase: 4, tone: 'warning' },
|
||||
cancelled: { label: '已取消', phase: 4, tone: 'neutral' },
|
||||
} as const satisfies Record<
|
||||
TuningSessionState,
|
||||
{ label: string; phase: number; tone: 'neutral' | 'accent' | 'success' | 'warning' }
|
||||
>;
|
||||
|
||||
export const ACTIVE_SESSION_STATES = new Set<TuningSessionState>([
|
||||
'queued',
|
||||
'running',
|
||||
'evaluating',
|
||||
'awaiting_approval',
|
||||
'paused',
|
||||
'interrupted',
|
||||
]);
|
||||
|
||||
export const TRIAL_STATE_LABELS: Record<string, string> = {
|
||||
queued: '等待调度',
|
||||
training: '训练中',
|
||||
evaluating: '评估中',
|
||||
completed: '完成',
|
||||
interrupted: '中断',
|
||||
failed: '失败',
|
||||
cancelled: '取消',
|
||||
};
|
||||
|
||||
export function parameterValue(config: RewardConfiguration | undefined, path: string): number {
|
||||
const definition = PARAMETER_BY_PATH.get(path);
|
||||
if (!definition) return Number.NaN;
|
||||
return config?.[definition.section][definition.key] ?? definition.defaultValue;
|
||||
}
|
||||
|
||||
export interface RewardDiffEntry {
|
||||
path: string;
|
||||
before: number;
|
||||
after: number;
|
||||
}
|
||||
|
||||
export function rewardConfigurationDiff(
|
||||
before: RewardConfiguration,
|
||||
after: RewardConfiguration,
|
||||
): RewardDiffEntry[] {
|
||||
const changes: RewardDiffEntry[] = [];
|
||||
for (const section of ['weights', 'params'] as const) {
|
||||
const keys = new Set([...Object.keys(before[section]), ...Object.keys(after[section])]);
|
||||
for (const key of keys) {
|
||||
const previous = before[section][key];
|
||||
const next = after[section][key];
|
||||
if (previous !== next)
|
||||
changes.push({ path: `${section}.${key}`, before: previous, after: next });
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
export function mergeRewardPatch(
|
||||
base: RewardConfiguration,
|
||||
patch: Partial<{ weights: Record<string, number>; params: Record<string, number> }>,
|
||||
): RewardConfiguration {
|
||||
return {
|
||||
weights: { ...base.weights, ...(patch.weights ?? {}) },
|
||||
params: { ...base.params, ...(patch.params ?? {}) },
|
||||
};
|
||||
}
|
||||
|
||||
export function latestCompletedTrial(trials: readonly TuningTrial[]): TuningTrial | undefined {
|
||||
return [...trials]
|
||||
.reverse()
|
||||
.find((trial) => trial.state === 'completed' && trial.evaluation !== undefined);
|
||||
}
|
||||
|
||||
export function formatMetric(value: number | null | undefined, digits = 4): string {
|
||||
if (value === null || value === undefined || !Number.isFinite(value)) return '—';
|
||||
const magnitude = Math.abs(value);
|
||||
if (magnitude !== 0 && (magnitude < 1e-3 || magnitude >= 1e4)) return value.toExponential(2);
|
||||
return value.toFixed(digits);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import type { TuningSession, TuningTrial } from '../training/types';
|
||||
import { resetTuningStore, useTuningStore } from './tuningStore';
|
||||
|
||||
const trial = (id: string, number: number, score: number): TuningTrial => ({
|
||||
id,
|
||||
sessionId: 's'.repeat(32),
|
||||
number,
|
||||
state: 'completed',
|
||||
rung: 0,
|
||||
targetIterations: 300,
|
||||
rewardConfig: { weights: { pose: 1 + number * 0.1 }, params: {} },
|
||||
score,
|
||||
eligible: true,
|
||||
evaluation: {
|
||||
metrics: { linear_velocity_rmse: 0.2 },
|
||||
score: { score, eligible: true, components: {} },
|
||||
},
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
endedAt: '2026-01-01T00:01:00Z',
|
||||
message: 'done',
|
||||
});
|
||||
|
||||
function session(): TuningSession {
|
||||
const first = trial('a'.repeat(32), 0, 0);
|
||||
const best = trial('b'.repeat(32), 1, 0.2);
|
||||
return {
|
||||
id: 's'.repeat(32),
|
||||
state: 'paused',
|
||||
mode: 'approval',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:02:00Z',
|
||||
config: {
|
||||
taskId: 'Unitree-Go2-Flat',
|
||||
mode: 'approval',
|
||||
runName: 'test',
|
||||
numEnvs: 16,
|
||||
seed: 42,
|
||||
gpuIds: [0],
|
||||
trialCount: 2,
|
||||
initialIterations: 300,
|
||||
middleIterations: 900,
|
||||
finalIterations: 2000,
|
||||
evalNumEnvs: 8,
|
||||
evalSteps: 10,
|
||||
objectiveWeights: {
|
||||
velocity_tracking: 0.35,
|
||||
action_smoothness: 0.2,
|
||||
posture_stability: 0.15,
|
||||
fall_avoidance: 0.15,
|
||||
foot_slip: 0.1,
|
||||
energy: 0.05,
|
||||
},
|
||||
fallbackEnabled: false,
|
||||
rungs: [300, 900, 2000],
|
||||
promote: [2, 2, 2],
|
||||
},
|
||||
objectiveWeights: {
|
||||
velocity_tracking: 0.35,
|
||||
action_smoothness: 0.2,
|
||||
posture_stability: 0.15,
|
||||
fall_avoidance: 0.15,
|
||||
foot_slip: 0.1,
|
||||
energy: 0.05,
|
||||
},
|
||||
message: 'paused',
|
||||
currentTrialId: best.id,
|
||||
bestTrialId: best.id,
|
||||
consecutiveNoImprove: 0,
|
||||
fallbackEnabled: false,
|
||||
trials: [first, best],
|
||||
proposals: [],
|
||||
audit: [],
|
||||
control: {
|
||||
runPolicy: 'step',
|
||||
dispatchTokens: 0,
|
||||
constraintsRevision: 3,
|
||||
constraints: { 'weights.pose': { kind: 'range', min: 0.8, max: 1.4 } },
|
||||
activeBaseTrialId: first.id,
|
||||
effectiveAfterCurrent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => resetTuningStore());
|
||||
|
||||
describe('tuningStore slices', () => {
|
||||
it('规范化 Session 实体并同步服务端控制状态', () => {
|
||||
useTuningStore.getState().applySessionSnapshot(session());
|
||||
const state = useTuningStore.getState();
|
||||
expect(state.sessionState).toBe('paused');
|
||||
expect(state.bestTrialId).toBe('b'.repeat(32));
|
||||
expect(state.trialIds).toHaveLength(2);
|
||||
expect(state.trialsById['b'.repeat(32)].score).toBe(0.2);
|
||||
expect(state.visibleTrialIds).toContain('b'.repeat(32));
|
||||
expect(state.runPolicy).toBe('step');
|
||||
expect(state.constraintsRevision).toBe(3);
|
||||
expect(state.activeBaseTrialId).toBe('a'.repeat(32));
|
||||
|
||||
useTuningStore.getState().setVisibleTrialIds([]);
|
||||
useTuningStore.getState().applySessionSnapshot(session());
|
||||
expect(useTuningStore.getState().visibleTrialIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('离开 Session 时清理指标缓冲与服务端控制快照', () => {
|
||||
useTuningStore.getState().applySessionSnapshot(session());
|
||||
useTuningStore.getState().enqueueMetricBatch({
|
||||
trialId: 'a',
|
||||
series: [{ tag: 'Train/reward', points: [{ step: 1, wallTime: 1, value: 2 }] }],
|
||||
});
|
||||
useTuningStore.getState().flushMetricBatches();
|
||||
|
||||
useTuningStore.getState().leaveSession();
|
||||
|
||||
const state = useTuningStore.getState();
|
||||
expect(state.sessionId).toBeUndefined();
|
||||
expect(state.metricBuffers).toEqual({});
|
||||
expect(state.knownMetricTags).toEqual([]);
|
||||
expect(state.runPolicy).toBe('continuous');
|
||||
expect(state.parameterConstraints).toEqual({});
|
||||
});
|
||||
|
||||
it('将多个 scalar delta 在一次 flush 中去重并只发布轻量 revision', () => {
|
||||
const store = useTuningStore.getState();
|
||||
store.enqueueMetricBatch({
|
||||
trialId: 'a',
|
||||
series: [{ tag: 'Train/reward', points: [{ step: 1, wallTime: 1, value: 2 }] }],
|
||||
});
|
||||
store.enqueueMetricBatch({
|
||||
trialId: 'a',
|
||||
series: [
|
||||
{
|
||||
tag: 'Train/reward',
|
||||
points: [
|
||||
{ step: 1, wallTime: 1, value: 3 },
|
||||
{ step: 2, wallTime: 2, value: 4 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(useTuningStore.getState().metricsRevision).toBe(0);
|
||||
useTuningStore.getState().flushMetricBatches();
|
||||
const state = useTuningStore.getState();
|
||||
expect(state.metricsRevision).toBe(1);
|
||||
expect(state.metricVersionByTrial.a).toBe(1);
|
||||
expect(state.knownMetricTags).toEqual(['Train/reward']);
|
||||
expect(state.metricCursor('a', 'Train/reward')).toBe(2);
|
||||
expect(state.readMetricSeries(['a'], 'Train/reward')[0].points).toEqual([
|
||||
{ step: 1, wallTime: 1, value: 3 },
|
||||
{ step: 2, wallTime: 2, value: 4 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,558 @@
|
||||
import { create, type StateCreator } from 'zustand';
|
||||
import { LocalTrainingClient } from '../training/LocalTrainingClient';
|
||||
import {
|
||||
DEFAULT_TRAINING_ENDPOINT,
|
||||
localStored,
|
||||
rememberTrainingConnection,
|
||||
sessionStored,
|
||||
TRAINING_ENDPOINT_KEY,
|
||||
TRAINING_TOKEN_KEY,
|
||||
TUNING_SESSION_KEY,
|
||||
} from '../training/storage';
|
||||
import type {
|
||||
ParameterConstraint,
|
||||
ScalarPoint,
|
||||
ScalarSeries,
|
||||
TuningCapability,
|
||||
TuningCreateRequest,
|
||||
TuningProposal,
|
||||
TuningSession,
|
||||
TuningSessionState,
|
||||
TuningTrial,
|
||||
} from '../training/types';
|
||||
import { ScalarRingBuffer } from './ScalarRingBuffer';
|
||||
|
||||
const MAX_VISIBLE_TRIALS = 6;
|
||||
const METRIC_BUFFER_CAPACITY = 4096;
|
||||
|
||||
function errorText(value: unknown): string {
|
||||
return value instanceof Error ? value.message : String(value);
|
||||
}
|
||||
|
||||
function sameIds(left: readonly string[], right: readonly string[]): boolean {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
function stableIds(previous: readonly string[], next: string[]): string[] {
|
||||
return sameIds(previous, next) ? (previous as string[]) : next;
|
||||
}
|
||||
|
||||
function mergeEntities<T extends { id: string }>(
|
||||
previous: Record<string, T>,
|
||||
values: readonly T[],
|
||||
): Record<string, T> {
|
||||
let changed = Object.keys(previous).length !== values.length;
|
||||
const next: Record<string, T> = {};
|
||||
for (const value of values) {
|
||||
const old = previous[value.id];
|
||||
const shared = old && JSON.stringify(old) === JSON.stringify(value) ? old : value;
|
||||
next[value.id] = shared;
|
||||
changed = changed || shared !== old;
|
||||
}
|
||||
return changed ? next : previous;
|
||||
}
|
||||
|
||||
function defaultVisibleTrialIds(
|
||||
trials: readonly TuningTrial[],
|
||||
bestTrialId: string | undefined,
|
||||
selectedTrialId: string | undefined,
|
||||
): string[] {
|
||||
const completed = trials
|
||||
.filter((trial) => trial.state === 'completed')
|
||||
.sort((left, right) => (right.score ?? -Infinity) - (left.score ?? -Infinity))
|
||||
.slice(0, MAX_VISIBLE_TRIALS)
|
||||
.map((trial) => trial.id);
|
||||
const ordered = [bestTrialId, selectedTrialId, ...completed].filter((id): id is string =>
|
||||
Boolean(id),
|
||||
);
|
||||
return [...new Set(ordered)].slice(0, MAX_VISIBLE_TRIALS);
|
||||
}
|
||||
|
||||
export interface SessionSlice {
|
||||
endpoint: string;
|
||||
token: string;
|
||||
capability?: TuningCapability;
|
||||
connectionState: 'idle' | 'connecting' | 'ready' | 'error';
|
||||
sessions: TuningSession[];
|
||||
sessionId?: string;
|
||||
sessionState?: TuningSessionState;
|
||||
sessionMode?: TuningSession['mode'];
|
||||
sessionMessage: string;
|
||||
sessionConfig?: TuningSession['config'];
|
||||
objectiveWeights?: TuningSession['objectiveWeights'];
|
||||
currentTrialId?: string;
|
||||
bestTrialId?: string;
|
||||
selectedTrialId?: string;
|
||||
consecutiveNoImprove: number;
|
||||
entitiesRevision: number;
|
||||
trialsById: Record<string, TuningTrial>;
|
||||
trialIds: string[];
|
||||
proposalsById: Record<string, TuningProposal>;
|
||||
proposalIds: string[];
|
||||
audit: TuningSession['audit'];
|
||||
error?: string;
|
||||
setConnection(endpoint: string, token: string): void;
|
||||
connect(): Promise<void>;
|
||||
openSession(id: string): Promise<void>;
|
||||
startSession(request: TuningCreateRequest): Promise<void>;
|
||||
refreshSession(expectedId?: string): Promise<void>;
|
||||
applySessionSnapshot(session: TuningSession): void;
|
||||
selectTrial(id: string): void;
|
||||
leaveSession(): void;
|
||||
clearError(): void;
|
||||
}
|
||||
|
||||
interface MetricBatch {
|
||||
trialId: string;
|
||||
series: ScalarSeries[];
|
||||
}
|
||||
|
||||
export interface TrialMetricSeries extends ScalarSeries {
|
||||
trialId: string;
|
||||
}
|
||||
|
||||
export interface MetricsBufferSlice {
|
||||
metricBuffers: Record<string, Record<string, ScalarRingBuffer>>;
|
||||
metricVersionByTrial: Record<string, number>;
|
||||
metricsRevision: number;
|
||||
knownMetricTags: string[];
|
||||
metricTag: string;
|
||||
smoothing: number;
|
||||
enqueueMetricBatch(batch: MetricBatch): void;
|
||||
flushMetricBatches(): void;
|
||||
clearMetricBuffers(trialId?: string): void;
|
||||
setMetricTag(tag: string): void;
|
||||
setSmoothing(value: number): void;
|
||||
readMetricSeries(trialIds: readonly string[], tag: string): TrialMetricSeries[];
|
||||
metricCursor(trialId: string, tag: string): number | undefined;
|
||||
}
|
||||
|
||||
export interface ControlSlice {
|
||||
visibleTrialIds: string[];
|
||||
runPolicy: 'continuous' | 'step';
|
||||
dispatchTokens: number;
|
||||
constraintsRevision: number;
|
||||
parameterConstraints: Record<string, ParameterConstraint>;
|
||||
activeBaseTrialId?: string;
|
||||
constraintsEffectiveAfterCurrent: boolean;
|
||||
busyOperations: string[];
|
||||
setTrialVisible(id: string, visible: boolean): void;
|
||||
setVisibleTrialIds(ids: string[]): void;
|
||||
pauseOrResume(): Promise<void>;
|
||||
cancelSession(): Promise<void>;
|
||||
setRuntimeMode(mode: TuningSession['mode']): Promise<void>;
|
||||
stepNextTrial(): Promise<void>;
|
||||
saveParameterConstraints(constraints: Record<string, ParameterConstraint>): Promise<void>;
|
||||
rollbackToTrial(trialId: string, checkpoint?: boolean): Promise<void>;
|
||||
decideProposal(
|
||||
proposalId: string,
|
||||
action: 'approve' | 'reject',
|
||||
payload: { feedback?: string; patch?: TuningProposal['patch'] },
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export type TuningStore = SessionSlice & MetricsBufferSlice & ControlSlice;
|
||||
type Slice<T> = StateCreator<TuningStore, [], [], T>;
|
||||
|
||||
function clientFrom(state: Pick<TuningStore, 'endpoint' | 'token'>): LocalTrainingClient {
|
||||
return new LocalTrainingClient(state.endpoint, state.token);
|
||||
}
|
||||
|
||||
function createSessionSlice(set: Parameters<Slice<SessionSlice>>[0], get: () => TuningStore) {
|
||||
const apply = (session: TuningSession) => get().applySessionSnapshot(session);
|
||||
return {
|
||||
endpoint: localStored(TRAINING_ENDPOINT_KEY, DEFAULT_TRAINING_ENDPOINT),
|
||||
token: sessionStored(TRAINING_TOKEN_KEY),
|
||||
connectionState: 'idle' as const,
|
||||
sessions: [],
|
||||
sessionMessage: '',
|
||||
consecutiveNoImprove: 0,
|
||||
entitiesRevision: 0,
|
||||
trialsById: {},
|
||||
trialIds: [],
|
||||
proposalsById: {},
|
||||
proposalIds: [],
|
||||
audit: [],
|
||||
setConnection: (endpoint: string, token: string) => set({ endpoint, token }),
|
||||
connect: async () => {
|
||||
set({ connectionState: 'connecting', error: undefined });
|
||||
try {
|
||||
const api = clientFrom(get());
|
||||
const [capability, sessions] = await Promise.all([
|
||||
api.tuningCapability(),
|
||||
api.tuningSessions(),
|
||||
]);
|
||||
rememberTrainingConnection(api.endpoint, api.token);
|
||||
set({ capability, sessions, connectionState: 'ready' });
|
||||
const remembered = localStored(TUNING_SESSION_KEY);
|
||||
const target = sessions.find((item) => item.id === remembered) ?? sessions[0];
|
||||
if (target) apply(await api.tuningSession(target.id));
|
||||
} catch (value) {
|
||||
set({ connectionState: 'error', error: errorText(value) });
|
||||
}
|
||||
},
|
||||
openSession: async (id: string) => {
|
||||
set({ connectionState: 'connecting', error: undefined });
|
||||
try {
|
||||
const session = await clientFrom(get()).tuningSession(id);
|
||||
apply(session);
|
||||
localStorage.setItem(TUNING_SESSION_KEY, id);
|
||||
set({ connectionState: 'ready' });
|
||||
} catch (value) {
|
||||
set({ connectionState: 'error', error: errorText(value) });
|
||||
}
|
||||
},
|
||||
startSession: async (request: TuningCreateRequest) => {
|
||||
set({ connectionState: 'connecting', error: undefined });
|
||||
try {
|
||||
const session = await clientFrom(get()).startTuning(request);
|
||||
apply(session);
|
||||
localStorage.setItem(TUNING_SESSION_KEY, session.id);
|
||||
set({ connectionState: 'ready' });
|
||||
} catch (value) {
|
||||
set({ connectionState: 'error', error: errorText(value) });
|
||||
}
|
||||
},
|
||||
refreshSession: async (expectedId?: string) => {
|
||||
const id = expectedId ?? get().sessionId;
|
||||
if (!id) return;
|
||||
try {
|
||||
const session = await clientFrom(get()).tuningSession(id);
|
||||
if (get().sessionId === id) apply(session);
|
||||
} catch (value) {
|
||||
if (get().sessionId === id) set({ error: errorText(value) });
|
||||
}
|
||||
},
|
||||
applySessionSnapshot: (session: TuningSession) => {
|
||||
if (get().sessionId && get().sessionId !== session.id) get().clearMetricBuffers();
|
||||
set((state) => {
|
||||
const trialsById = mergeEntities(state.trialsById, session.trials ?? []);
|
||||
const proposalsById = mergeEntities(state.proposalsById, session.proposals ?? []);
|
||||
const trialIds = stableIds(
|
||||
state.trialIds,
|
||||
(session.trials ?? []).map((trial) => trial.id),
|
||||
);
|
||||
const proposalIds = stableIds(
|
||||
state.proposalIds,
|
||||
(session.proposals ?? []).map((proposal) => proposal.id),
|
||||
);
|
||||
const trialIdSet = new Set(trialIds);
|
||||
const selectedTrialId =
|
||||
(state.selectedTrialId && trialIdSet.has(state.selectedTrialId)
|
||||
? state.selectedTrialId
|
||||
: undefined) ??
|
||||
session.currentTrialId ??
|
||||
session.bestTrialId ??
|
||||
trialIds.at(-1);
|
||||
const currentVisible = state.visibleTrialIds.filter((id) => trialIdSet.has(id));
|
||||
const sameSession = state.sessionId === session.id;
|
||||
const newlyImportant = sameSession
|
||||
? [
|
||||
session.bestTrialId !== state.bestTrialId ? session.bestTrialId : undefined,
|
||||
session.currentTrialId !== state.currentTrialId ? session.currentTrialId : undefined,
|
||||
]
|
||||
: [];
|
||||
const visibleTrialIds = sameSession
|
||||
? [
|
||||
...new Set(
|
||||
[...newlyImportant, ...currentVisible].filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
].slice(0, MAX_VISIBLE_TRIALS)
|
||||
: defaultVisibleTrialIds(session.trials ?? [], session.bestTrialId, selectedTrialId);
|
||||
const control = session.control;
|
||||
return {
|
||||
sessionId: session.id,
|
||||
sessionState: session.state,
|
||||
sessionMode: session.mode,
|
||||
sessionMessage: session.message,
|
||||
sessionConfig: session.config,
|
||||
objectiveWeights: session.objectiveWeights,
|
||||
currentTrialId: session.currentTrialId,
|
||||
bestTrialId: session.bestTrialId,
|
||||
selectedTrialId,
|
||||
consecutiveNoImprove: session.consecutiveNoImprove,
|
||||
entitiesRevision:
|
||||
trialsById !== state.trialsById || proposalsById !== state.proposalsById
|
||||
? state.entitiesRevision + 1
|
||||
: state.entitiesRevision,
|
||||
trialsById,
|
||||
trialIds,
|
||||
proposalsById,
|
||||
proposalIds,
|
||||
audit: session.audit ?? [],
|
||||
visibleTrialIds: stableIds(state.visibleTrialIds, visibleTrialIds),
|
||||
runPolicy: control?.runPolicy ?? 'continuous',
|
||||
dispatchTokens: control?.dispatchTokens ?? 0,
|
||||
constraintsRevision: control?.constraintsRevision ?? 0,
|
||||
parameterConstraints: control?.constraints ?? {},
|
||||
activeBaseTrialId: control?.activeBaseTrialId,
|
||||
constraintsEffectiveAfterCurrent: control?.effectiveAfterCurrent ?? false,
|
||||
};
|
||||
});
|
||||
},
|
||||
selectTrial: (selectedTrialId: string) => set({ selectedTrialId }),
|
||||
leaveSession: () => {
|
||||
get().clearMetricBuffers();
|
||||
set({
|
||||
sessionId: undefined,
|
||||
sessionState: undefined,
|
||||
sessionMode: undefined,
|
||||
sessionMessage: '',
|
||||
sessionConfig: undefined,
|
||||
objectiveWeights: undefined,
|
||||
currentTrialId: undefined,
|
||||
bestTrialId: undefined,
|
||||
selectedTrialId: undefined,
|
||||
consecutiveNoImprove: 0,
|
||||
entitiesRevision: 0,
|
||||
trialsById: {},
|
||||
trialIds: [],
|
||||
proposalsById: {},
|
||||
proposalIds: [],
|
||||
audit: [],
|
||||
visibleTrialIds: [],
|
||||
runPolicy: 'continuous',
|
||||
dispatchTokens: 0,
|
||||
constraintsRevision: 0,
|
||||
parameterConstraints: {},
|
||||
activeBaseTrialId: undefined,
|
||||
constraintsEffectiveAfterCurrent: false,
|
||||
busyOperations: [],
|
||||
});
|
||||
},
|
||||
clearError: () => set({ error: undefined }),
|
||||
} satisfies SessionSlice;
|
||||
}
|
||||
|
||||
const pendingMetricBatches = new Map<string, Map<string, ScalarPoint[]>>();
|
||||
let metricFrame = 0;
|
||||
|
||||
function scheduleMetricFrame(flush: () => void): void {
|
||||
if (metricFrame) return;
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
metricFrame = requestAnimationFrame(() => {
|
||||
metricFrame = 0;
|
||||
flush();
|
||||
});
|
||||
} else {
|
||||
metricFrame = -1;
|
||||
queueMicrotask(() => {
|
||||
metricFrame = 0;
|
||||
flush();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const createMetricsBufferSlice: Slice<MetricsBufferSlice> = (set, get) => ({
|
||||
metricBuffers: {},
|
||||
metricVersionByTrial: {},
|
||||
metricsRevision: 0,
|
||||
knownMetricTags: [],
|
||||
metricTag: '',
|
||||
smoothing: 0.25,
|
||||
enqueueMetricBatch: ({ trialId, series }) => {
|
||||
let trial = pendingMetricBatches.get(trialId);
|
||||
if (!trial) {
|
||||
trial = new Map();
|
||||
pendingMetricBatches.set(trialId, trial);
|
||||
}
|
||||
for (const item of series) {
|
||||
const points = trial.get(item.tag) ?? [];
|
||||
points.push(...item.points);
|
||||
trial.set(item.tag, points);
|
||||
}
|
||||
scheduleMetricFrame(get().flushMetricBatches);
|
||||
},
|
||||
flushMetricBatches: () => {
|
||||
if (!pendingMetricBatches.size) return;
|
||||
const batches = [...pendingMetricBatches];
|
||||
pendingMetricBatches.clear();
|
||||
set((state) => {
|
||||
const metricBuffers = { ...state.metricBuffers };
|
||||
const metricVersionByTrial = { ...state.metricVersionByTrial };
|
||||
const known = new Set(state.knownMetricTags);
|
||||
let changed = false;
|
||||
for (const [trialId, tags] of batches) {
|
||||
const trialBuffers = { ...(metricBuffers[trialId] ?? {}) };
|
||||
let trialChanged = false;
|
||||
for (const [tag, points] of tags) {
|
||||
known.add(tag);
|
||||
const buffer = trialBuffers[tag] ?? new ScalarRingBuffer(METRIC_BUFFER_CAPACITY);
|
||||
if (buffer.appendMany(points)) {
|
||||
trialBuffers[tag] = buffer;
|
||||
trialChanged = true;
|
||||
}
|
||||
}
|
||||
if (trialChanged) {
|
||||
metricBuffers[trialId] = trialBuffers;
|
||||
metricVersionByTrial[trialId] = (metricVersionByTrial[trialId] ?? 0) + 1;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
const knownMetricTags = [...known].sort();
|
||||
const tagsChanged = !sameIds(state.knownMetricTags, knownMetricTags);
|
||||
if (!changed && !tagsChanged) return state;
|
||||
return {
|
||||
metricBuffers,
|
||||
metricVersionByTrial,
|
||||
metricsRevision: state.metricsRevision + (changed ? 1 : 0),
|
||||
knownMetricTags: tagsChanged ? knownMetricTags : state.knownMetricTags,
|
||||
};
|
||||
});
|
||||
},
|
||||
clearMetricBuffers: (trialId) => {
|
||||
if (trialId) {
|
||||
pendingMetricBatches.delete(trialId);
|
||||
set((state) => {
|
||||
const metricBuffers = { ...state.metricBuffers };
|
||||
const metricVersionByTrial = { ...state.metricVersionByTrial };
|
||||
delete metricBuffers[trialId];
|
||||
delete metricVersionByTrial[trialId];
|
||||
return {
|
||||
metricBuffers,
|
||||
metricVersionByTrial,
|
||||
metricsRevision: state.metricsRevision + 1,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
pendingMetricBatches.clear();
|
||||
set((state) => ({
|
||||
metricBuffers: {},
|
||||
metricVersionByTrial: {},
|
||||
knownMetricTags: [],
|
||||
metricTag: '',
|
||||
metricsRevision: state.metricsRevision + 1,
|
||||
}));
|
||||
}
|
||||
},
|
||||
setMetricTag: (metricTag) => set({ metricTag }),
|
||||
setSmoothing: (smoothing) => set({ smoothing: Math.min(0.95, Math.max(0, smoothing)) }),
|
||||
readMetricSeries: (trialIds, tag) => {
|
||||
const { metricBuffers } = get();
|
||||
return trialIds.flatMap((trialId) => {
|
||||
const buffer = metricBuffers[trialId]?.[tag];
|
||||
return buffer ? [{ trialId, tag, points: buffer.snapshot() }] : [];
|
||||
});
|
||||
},
|
||||
metricCursor: (trialId, tag) => get().metricBuffers[trialId]?.[tag]?.latestStep,
|
||||
});
|
||||
|
||||
function createControlSlice(set: Parameters<Slice<ControlSlice>>[0], get: () => TuningStore) {
|
||||
const run = async (name: string, operation: () => Promise<TuningSession>) => {
|
||||
if (get().busyOperations.includes(name)) return;
|
||||
set((state) => ({ busyOperations: [...state.busyOperations, name], error: undefined }));
|
||||
try {
|
||||
get().applySessionSnapshot(await operation());
|
||||
} catch (value) {
|
||||
set({ error: errorText(value) });
|
||||
} finally {
|
||||
set((state) => ({ busyOperations: state.busyOperations.filter((item) => item !== name) }));
|
||||
}
|
||||
};
|
||||
return {
|
||||
visibleTrialIds: [],
|
||||
runPolicy: 'continuous' as const,
|
||||
dispatchTokens: 0,
|
||||
constraintsRevision: 0,
|
||||
parameterConstraints: {},
|
||||
constraintsEffectiveAfterCurrent: false,
|
||||
busyOperations: [],
|
||||
setTrialVisible: (id: string, visible: boolean) =>
|
||||
set((state) => {
|
||||
if (visible && state.visibleTrialIds.includes(id)) return state;
|
||||
if (!visible)
|
||||
return { visibleTrialIds: state.visibleTrialIds.filter((value) => value !== id) };
|
||||
return { visibleTrialIds: [...state.visibleTrialIds, id].slice(-MAX_VISIBLE_TRIALS) };
|
||||
}),
|
||||
setVisibleTrialIds: (ids: string[]) =>
|
||||
set({ visibleTrialIds: [...new Set(ids)].slice(0, MAX_VISIBLE_TRIALS) }),
|
||||
pauseOrResume: async () => {
|
||||
const { sessionId, sessionState } = get();
|
||||
if (!sessionId) return;
|
||||
const action =
|
||||
sessionState === 'paused' || sessionState === 'interrupted' ? 'resume' : 'pause';
|
||||
await run(action, () => clientFrom(get()).tuningAction(sessionId, action));
|
||||
},
|
||||
cancelSession: async () => {
|
||||
const id = get().sessionId;
|
||||
if (id) await run('cancel', () => clientFrom(get()).cancelTuning(id));
|
||||
},
|
||||
setRuntimeMode: async (mode: TuningSession['mode']) => {
|
||||
const id = get().sessionId;
|
||||
if (id && mode !== get().sessionMode)
|
||||
await run('mode', () => clientFrom(get()).setTuningMode(id, mode));
|
||||
},
|
||||
stepNextTrial: async () => {
|
||||
const id = get().sessionId;
|
||||
if (id) await run('step', () => clientFrom(get()).stepTuning(id));
|
||||
},
|
||||
saveParameterConstraints: async (constraints: Record<string, ParameterConstraint>) => {
|
||||
const { sessionId, constraintsRevision } = get();
|
||||
if (sessionId)
|
||||
await run('constraints', () =>
|
||||
clientFrom(get()).setTuningConstraints(sessionId, constraintsRevision, constraints),
|
||||
);
|
||||
},
|
||||
rollbackToTrial: async (trialId: string, checkpoint = false) => {
|
||||
const id = get().sessionId;
|
||||
if (id)
|
||||
await run('rollback', () => clientFrom(get()).rollbackTuning(id, trialId, checkpoint));
|
||||
},
|
||||
decideProposal: async (proposalId, action, payload) => {
|
||||
const id = get().sessionId;
|
||||
if (id)
|
||||
await run(`proposal:${proposalId}`, () =>
|
||||
clientFrom(get()).decideProposal(id, proposalId, action, payload),
|
||||
);
|
||||
},
|
||||
} satisfies ControlSlice;
|
||||
}
|
||||
|
||||
export const useTuningStore = create<TuningStore>()((set, get, api) => ({
|
||||
...createSessionSlice(set, get),
|
||||
...createMetricsBufferSlice(set, get, api),
|
||||
...createControlSlice(set, get),
|
||||
}));
|
||||
|
||||
export function resetTuningStore(): void {
|
||||
pendingMetricBatches.clear();
|
||||
if (metricFrame > 0 && typeof cancelAnimationFrame === 'function')
|
||||
cancelAnimationFrame(metricFrame);
|
||||
metricFrame = 0;
|
||||
useTuningStore.setState({
|
||||
capability: undefined,
|
||||
connectionState: 'idle',
|
||||
sessions: [],
|
||||
sessionId: undefined,
|
||||
sessionState: undefined,
|
||||
sessionMode: undefined,
|
||||
sessionMessage: '',
|
||||
sessionConfig: undefined,
|
||||
objectiveWeights: undefined,
|
||||
currentTrialId: undefined,
|
||||
bestTrialId: undefined,
|
||||
selectedTrialId: undefined,
|
||||
consecutiveNoImprove: 0,
|
||||
entitiesRevision: 0,
|
||||
trialsById: {},
|
||||
trialIds: [],
|
||||
proposalsById: {},
|
||||
proposalIds: [],
|
||||
audit: [],
|
||||
error: undefined,
|
||||
metricBuffers: {},
|
||||
metricVersionByTrial: {},
|
||||
metricsRevision: 0,
|
||||
knownMetricTags: [],
|
||||
metricTag: '',
|
||||
smoothing: 0.25,
|
||||
visibleTrialIds: [],
|
||||
runPolicy: 'continuous',
|
||||
dispatchTokens: 0,
|
||||
constraintsRevision: 0,
|
||||
parameterConstraints: {},
|
||||
activeBaseTrialId: undefined,
|
||||
constraintsEffectiveAfterCurrent: false,
|
||||
busyOperations: [],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { LocalTrainingClient } from '../training/LocalTrainingClient';
|
||||
import { ACTIVE_SESSION_STATES } from './domain';
|
||||
import { useTuningStore } from './tuningStore';
|
||||
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
|
||||
/**
|
||||
* 非重入的 1Hz 轮询器。每轮请求完成后才安排下一轮,切换 session/tag 时 Abort,
|
||||
* 避免旧响应覆盖新视图;scalar 先进入 store 的 rAF 队列再一次性发布。
|
||||
*/
|
||||
export function useTuningPolling(): void {
|
||||
const [endpoint, token, sessionId, sessionState] = useTuningStore(
|
||||
useShallow(
|
||||
(state) => [state.endpoint, state.token, state.sessionId, state.sessionState] as const,
|
||||
),
|
||||
);
|
||||
const [visibleTrialIds, metricTag] = useTuningStore(
|
||||
useShallow((state) => [state.visibleTrialIds, state.metricTag] as const),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || !sessionState || !ACTIVE_SESSION_STATES.has(sessionState)) return;
|
||||
let disposed = false;
|
||||
let timer = 0;
|
||||
const poll = async () => {
|
||||
await useTuningStore.getState().refreshSession(sessionId);
|
||||
if (!disposed) timer = window.setTimeout(() => void poll(), POLL_INTERVAL_MS);
|
||||
};
|
||||
timer = window.setTimeout(() => void poll(), POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [sessionId, sessionState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || !token || visibleTrialIds.length === 0) return;
|
||||
let disposed = false;
|
||||
let timer = 0;
|
||||
let controller: AbortController | undefined;
|
||||
const hydratedTrials = new Set<string>();
|
||||
const repeat = Boolean(sessionState && ACTIVE_SESSION_STATES.has(sessionState));
|
||||
const poll = async () => {
|
||||
controller = new AbortController();
|
||||
const api = new LocalTrainingClient(endpoint, token);
|
||||
const tags = metricTag ? [metricTag] : [];
|
||||
const requests = visibleTrialIds.map(async (trialId) => {
|
||||
// 切换 tag/Trial 后先补齐完整窗口,再使用 step cursor。否则用于发现 tag 的
|
||||
// 128 点预览会把 cursor 推到末尾,使选中曲线永远无法回填 4096 点历史。
|
||||
const cursor =
|
||||
metricTag && hydratedTrials.has(trialId)
|
||||
? useTuningStore.getState().metricCursor(trialId, metricTag)
|
||||
: undefined;
|
||||
const response = await api.tuningMetrics(
|
||||
sessionId,
|
||||
trialId,
|
||||
tags,
|
||||
metricTag ? 4096 : 128,
|
||||
cursor,
|
||||
controller?.signal,
|
||||
);
|
||||
hydratedTrials.add(trialId);
|
||||
if (!disposed && useTuningStore.getState().sessionId === sessionId)
|
||||
useTuningStore.getState().enqueueMetricBatch({
|
||||
trialId,
|
||||
series: response.series,
|
||||
});
|
||||
});
|
||||
const results = await Promise.allSettled(requests);
|
||||
if (!disposed) {
|
||||
const failure = results.find(
|
||||
(result): result is PromiseRejectedResult =>
|
||||
result.status === 'rejected' && result.reason?.name !== 'AbortError',
|
||||
);
|
||||
if (failure)
|
||||
useTuningStore.setState({
|
||||
error:
|
||||
failure.reason instanceof Error ? failure.reason.message : String(failure.reason),
|
||||
});
|
||||
// 终态 Session 只做一次最终补齐,避免历史会话永久保持 1 Hz 网络活动。
|
||||
if (repeat) timer = window.setTimeout(() => void poll(), POLL_INTERVAL_MS);
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
return () => {
|
||||
disposed = true;
|
||||
controller?.abort();
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [endpoint, metricTag, sessionId, sessionState, token, visibleTrialIds]);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="zh-CN" class="theme-dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
|
||||
Reference in New Issue
Block a user