"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(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 => { 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 (
void onStart(s, c)} disabled={busy} /> {showProgress && result ? ( ) : null} {phase === "awaiting" && awaitingChapter !== null && jobId !== null ? ( ) : null} {phase === "failed" ? (

{poll.error ?? "链运行失败,请重试。"}

) : null} {phase === "completed" ? (

本链已全部跑完。

) : null}
); }