238 lines
7.3 KiB
TypeScript
238 lines
7.3 KiB
TypeScript
|
|
import { useEffect, useRef, useState } from "react";
|
||
|
|
import { Banner, Button, Spin, TextArea, Toast } from "@douyinfe/semi-ui-19";
|
||
|
|
import { api, formatTime, jobStateLabels, post } from "../api";
|
||
|
|
import type { Job } from "../types";
|
||
|
|
|
||
|
|
type Description = {
|
||
|
|
idea: string;
|
||
|
|
data_rationale: string;
|
||
|
|
operator_rationale: string;
|
||
|
|
};
|
||
|
|
type Submission = {
|
||
|
|
snapshot: string;
|
||
|
|
sections: Record<string, { code: string | null; description: string }>;
|
||
|
|
descriptions: Record<string, Description>;
|
||
|
|
model: string;
|
||
|
|
can_generate: boolean;
|
||
|
|
can_check: boolean;
|
||
|
|
job: Job | null;
|
||
|
|
};
|
||
|
|
const fields = [
|
||
|
|
["idea", "Idea"],
|
||
|
|
["data_rationale", "Rationale for data used"],
|
||
|
|
["operator_rationale", "Rationale for operators used"],
|
||
|
|
] as const;
|
||
|
|
const render = (value: Description) =>
|
||
|
|
fields.map(([key, title]) => `${title}: ${value[key].trim()}`).join("\n");
|
||
|
|
const activeStates = [
|
||
|
|
"queued",
|
||
|
|
"running",
|
||
|
|
"waiting_auth",
|
||
|
|
"waiting_connection",
|
||
|
|
];
|
||
|
|
|
||
|
|
export function SubmissionPanel({
|
||
|
|
id,
|
||
|
|
version,
|
||
|
|
onTask,
|
||
|
|
}: {
|
||
|
|
id: string;
|
||
|
|
version: string;
|
||
|
|
onTask: () => void;
|
||
|
|
}) {
|
||
|
|
const [data, setData] = useState<Submission | null>(null);
|
||
|
|
const [draft, setDraft] = useState<Record<string, Description>>({});
|
||
|
|
const [snapshot, setSnapshot] = useState("");
|
||
|
|
const [busy, setBusy] = useState("");
|
||
|
|
const [error, setError] = useState("");
|
||
|
|
const dirty = useRef(false);
|
||
|
|
const mounted = useRef(true);
|
||
|
|
useEffect(() => {
|
||
|
|
mounted.current = true;
|
||
|
|
return () => {
|
||
|
|
mounted.current = false;
|
||
|
|
};
|
||
|
|
}, []);
|
||
|
|
useEffect(() => {
|
||
|
|
let active = true;
|
||
|
|
api<Submission>(`/alphas/${id}/submission`)
|
||
|
|
.then((value) => {
|
||
|
|
if (!active) return;
|
||
|
|
setData(value);
|
||
|
|
if (!dirty.current) {
|
||
|
|
setDraft(value.descriptions);
|
||
|
|
setSnapshot(value.snapshot);
|
||
|
|
}
|
||
|
|
})
|
||
|
|
.catch((e) => {
|
||
|
|
if (active) setError(e.message);
|
||
|
|
});
|
||
|
|
return () => {
|
||
|
|
active = false;
|
||
|
|
};
|
||
|
|
}, [id, version]);
|
||
|
|
|
||
|
|
async function generate() {
|
||
|
|
setBusy("generate");
|
||
|
|
setError("");
|
||
|
|
try {
|
||
|
|
const result = await post<{ descriptions: Record<string, Description> }>(
|
||
|
|
`/alphas/${id}/description/generate`,
|
||
|
|
{ snapshot },
|
||
|
|
);
|
||
|
|
if (!mounted.current) return;
|
||
|
|
dirty.current = true;
|
||
|
|
setDraft(result.descriptions);
|
||
|
|
Toast.success("Description 已生成,可修改后写回");
|
||
|
|
} catch (e) {
|
||
|
|
if (mounted.current) setError((e as Error).message);
|
||
|
|
} finally {
|
||
|
|
if (mounted.current) setBusy("");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
async function check() {
|
||
|
|
setBusy("check");
|
||
|
|
setError("");
|
||
|
|
try {
|
||
|
|
const job = await post<Job>(`/alphas/${id}/submission-check`, {
|
||
|
|
snapshot,
|
||
|
|
descriptions: draft,
|
||
|
|
});
|
||
|
|
if (!mounted.current) return;
|
||
|
|
// Keep the reviewed draft visible while the durable task runs.
|
||
|
|
dirty.current = true;
|
||
|
|
setData((current) => (current ? { ...current, job } : current));
|
||
|
|
onTask();
|
||
|
|
} catch (e) {
|
||
|
|
if (mounted.current) setError((e as Error).message);
|
||
|
|
} finally {
|
||
|
|
if (mounted.current) setBusy("");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (!data)
|
||
|
|
return error ? <Banner type="danger" description={error} /> : <Spin />;
|
||
|
|
const running = !!data.job && activeStates.includes(data.job.status);
|
||
|
|
const conflict = snapshot !== data.snapshot;
|
||
|
|
const valid =
|
||
|
|
Object.keys(draft).length > 0 &&
|
||
|
|
Object.values(draft).every(
|
||
|
|
(value) =>
|
||
|
|
fields.every(([key]) => value[key].trim()) &&
|
||
|
|
Array.from(render(value)).length >= 100,
|
||
|
|
);
|
||
|
|
return (
|
||
|
|
<div className="detail-section">
|
||
|
|
<h3>Description 与 Check submission</h3>
|
||
|
|
<p className="muted">
|
||
|
|
AI 生成后可逐段修改。写回并检查会更新 BRAIN 的
|
||
|
|
Description,随后获取平台提交检查结果,不会正式提交 Alpha。
|
||
|
|
</p>
|
||
|
|
{error && <Banner type="danger" description={error} />}
|
||
|
|
{!data.can_check && (
|
||
|
|
<Banner
|
||
|
|
type="info"
|
||
|
|
description="写回并检查需要待提交 Alpha,并先取得有效、样本完整且低于阈值的本地自相关结果。"
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
{!data.can_generate && (
|
||
|
|
<p className="muted">
|
||
|
|
请在个人信息 → 大模型服务中配置 Description 模型。也可以手动填写。
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
<div className="inline-actions">
|
||
|
|
<Button
|
||
|
|
onClick={() => void generate()}
|
||
|
|
loading={busy === "generate"}
|
||
|
|
disabled={!!busy || running || conflict || !data.can_generate}
|
||
|
|
>
|
||
|
|
AI 生成三段 Description
|
||
|
|
</Button>
|
||
|
|
{data.model && <span className="muted">模型:{data.model}</span>}
|
||
|
|
</div>
|
||
|
|
{conflict && (
|
||
|
|
<Banner
|
||
|
|
type="warning"
|
||
|
|
description="平台快照已更新,当前草稿仍保留。请复制需要的内容,再载入最新描述后核对。"
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
{Object.entries(draft).map(([section, value]) => (
|
||
|
|
<section key={section} className="detail-section">
|
||
|
|
<h4>
|
||
|
|
{section === "regular"
|
||
|
|
? "Regular"
|
||
|
|
: section === "selection"
|
||
|
|
? "Selection"
|
||
|
|
: "Combo"}
|
||
|
|
</h4>
|
||
|
|
{data.sections[section]?.description && (
|
||
|
|
<details>
|
||
|
|
<summary>已同步的 Description</summary>
|
||
|
|
<pre className="code-block">
|
||
|
|
{data.sections[section].description}
|
||
|
|
</pre>
|
||
|
|
</details>
|
||
|
|
)}
|
||
|
|
{fields.map(([key, title]) => (
|
||
|
|
<label key={key}>
|
||
|
|
{title}:
|
||
|
|
<TextArea
|
||
|
|
aria-label={`${section} ${title}`}
|
||
|
|
value={value[key]}
|
||
|
|
rows={3}
|
||
|
|
maxCount={6000}
|
||
|
|
disabled={!!busy || running}
|
||
|
|
onChange={(text) => {
|
||
|
|
dirty.current = true;
|
||
|
|
setDraft((current) => ({
|
||
|
|
...current,
|
||
|
|
[section]: { ...current[section], [key]: text },
|
||
|
|
}));
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</label>
|
||
|
|
))}
|
||
|
|
<p className="muted">
|
||
|
|
总长度:{Array.from(render(value)).length} 字符,至少 100
|
||
|
|
字符(含标题和换行)。三段内容均需填写。
|
||
|
|
</p>
|
||
|
|
</section>
|
||
|
|
))}
|
||
|
|
<div className="inline-actions">
|
||
|
|
<Button
|
||
|
|
theme="solid"
|
||
|
|
loading={busy === "check"}
|
||
|
|
disabled={!!busy || running || conflict || !valid || !data.can_check}
|
||
|
|
onClick={() => void check()}
|
||
|
|
>
|
||
|
|
写回 Description 并检查
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
disabled={!!busy || running}
|
||
|
|
onClick={() => {
|
||
|
|
dirty.current = false;
|
||
|
|
setDraft(data.descriptions);
|
||
|
|
setSnapshot(data.snapshot);
|
||
|
|
setError("");
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
载入最新描述
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
{data.job && (
|
||
|
|
<div role="status">
|
||
|
|
<p>
|
||
|
|
最近任务:{jobStateLabels[data.job.status] ?? data.job.status}
|
||
|
|
{data.job.status === "completed" && ",请在“指标与检查”查看结果"}
|
||
|
|
{data.job.next_retry_at &&
|
||
|
|
` · 下次轮询:${formatTime(data.job.next_retry_at)}`}
|
||
|
|
</p>
|
||
|
|
{data.job.error && (
|
||
|
|
<Banner type="danger" description={data.job.error} />
|
||
|
|
)}
|
||
|
|
<Button onClick={onTask}>查看后台任务</Button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|