524 lines
20 KiB
TypeScript
524 lines
20 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|