feat(web): Scope B B1 — 多章工作流链前端 UI(发起/轮询/裁决续跑)
链页 app/projects/[id]/chains(RSC fetchProject)+ ChainPage/ChainStarter/
ChainProgress/ChainAdjudication;lib/chain 纯函数(result 收窄/相位映射/resume 组装)。
复用 useJobPoll 轮询、ConflictCard + decisions.ts 裁决、normalizeConflicts/latestReview、
friendlyError;nav 加 chains 入口。awaiting(interrupt)→ 读该章冲突渲 ConflictCard →
POST .../chains/runs/{job_id}/resume 续跑。gen:api 纳入 chains run/resume。
守 #5(链暂停=等裁决,token/正文不入 job result)。
This commit is contained in:
119
apps/web/components/chain/ChainPage.tsx
Normal file
119
apps/web/components/chain/ChainPage.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { chainPhase, chainResultView } from "@/lib/chain/chain";
|
||||
import { friendlyError } from "@/lib/errors/messages";
|
||||
import { useJobPoll } from "@/lib/jobs/useJobPoll";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import { ChainAdjudication } from "./ChainAdjudication";
|
||||
import { ChainProgress } from "./ChainProgress";
|
||||
import { ChainStarter } from "./ChainStarter";
|
||||
|
||||
interface ChainPageProps {
|
||||
project: ProjectResponse;
|
||||
}
|
||||
|
||||
const CHAIN_KEY = "draft_volume";
|
||||
|
||||
// 多章工作流链页(Scope B B1):发起链(POST run)→job 轮询进度→interrupt 命中则读该章冲突裁决→
|
||||
// POST resume 续跑。复用 useJobPoll(轮询)+ ConflictCard/decisions(裁决)+ friendlyError(文案)。
|
||||
// 链是后台任务,走 job 轮询而非 SSE。awaiting 时停轮询(后端把 job 置 awaiting_input,
|
||||
// narrowJob 抹平成 queued 会无限轮询)→ 裁决面板 resume 后再重启轮询同一 job。
|
||||
export function ChainPage({ project }: ChainPageProps) {
|
||||
const poll = useJobPoll();
|
||||
const toast = useToast();
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
|
||||
const job = poll.job;
|
||||
const result = useMemo(
|
||||
() => (job ? chainResultView(job) : null),
|
||||
[job],
|
||||
);
|
||||
const awaitingChapter = result?.awaitingChapter ?? null;
|
||||
const phase = chainPhase(poll.status, awaitingChapter);
|
||||
|
||||
// awaiting 命中:停轮询(后端 job=awaiting_input 被 narrowJob 抹成 queued 会空转)。
|
||||
useEffect(() => {
|
||||
if (phase === "awaiting") poll.reset();
|
||||
}, [phase, poll]);
|
||||
|
||||
const onStart = useCallback(
|
||||
async (startChapterNo: number, count: number): Promise<void> => {
|
||||
setStarting(true);
|
||||
try {
|
||||
const { data, error } = await api.POST(
|
||||
"/projects/{project_id}/chains/{chain_key}/run",
|
||||
{
|
||||
params: {
|
||||
path: { project_id: project.id, chain_key: CHAIN_KEY },
|
||||
},
|
||||
body: { start_chapter_no: startChapterNo, count },
|
||||
},
|
||||
);
|
||||
if (error || !data) {
|
||||
toast(friendlyError(undefined).text, "error");
|
||||
return;
|
||||
}
|
||||
setJobId(data.job_id);
|
||||
poll.poll(data.job_id);
|
||||
} catch {
|
||||
toast("发起链请求异常,请检查网络。", "error");
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
},
|
||||
[project.id, poll, toast],
|
||||
);
|
||||
|
||||
// 续跑成功:重启轮询同一 job(resume 已 202,图从检查点续)。
|
||||
const onResumed = useCallback((): void => {
|
||||
if (jobId) poll.poll(jobId);
|
||||
}, [jobId, poll]);
|
||||
|
||||
const showProgress = jobId !== null && result !== null;
|
||||
// 链运行中/等待裁决时禁止重复发起。
|
||||
const busy = starting || (jobId !== null && phase === "running");
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title={project.title}
|
||||
subtitle="多章工作流链"
|
||||
projectId={project.id}
|
||||
activeNav="chains"
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 p-6">
|
||||
<ChainStarter onStart={(s, c) => void onStart(s, c)} disabled={busy} />
|
||||
|
||||
{showProgress && result ? (
|
||||
<ChainProgress phase={phase} progress={poll.progress} result={result} />
|
||||
) : null}
|
||||
|
||||
{phase === "awaiting" &&
|
||||
awaitingChapter !== null &&
|
||||
jobId !== null ? (
|
||||
<ChainAdjudication
|
||||
projectId={project.id}
|
||||
jobId={jobId}
|
||||
chapterNo={awaitingChapter}
|
||||
onResumed={onResumed}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{phase === "failed" ? (
|
||||
<p className="text-sm text-conflict">
|
||||
{poll.error ?? "链运行失败,请重试。"}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{phase === "completed" ? (
|
||||
<p className="text-sm text-pass">本链已全部跑完。</p>
|
||||
) : null}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user