feat(tuning): release V0.8.2 Agent 界面重构
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled

This commit is contained in:
2026-09-03 16:25:53 +08:00
parent a9b07e0abf
commit 63d67a645b
35 changed files with 4847 additions and 811 deletions
@@ -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>
</>
);
}