Files
Mujoco_WASM/web_platform/src/tuning/TuningControlToolbar.tsx
T
chenlin 63d67a645b
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
feat(tuning): release V0.8.2 Agent 界面重构
2026-09-03 16:25:53 +08:00

524 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
</>
);
}