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:
Yaojia Wang
2026-06-23 19:21:00 +02:00
parent 416caed56b
commit a2dcd89881
11 changed files with 874 additions and 4 deletions

View 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],
);
// 续跑成功:重启轮询同一 jobresume 已 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>
);
}