Files
writer-work-flow/apps/web/components/chain/ChainPage.tsx

146 lines
4.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { useToast } from "@/components/Toast";
import { Button } from "@/components/ui/Button";
import { StatusNote } from "@/components/ui/StatusNote";
import { api } from "@/lib/api/client";
import { chainPhase, chainResultView, type ChainKind } from "@/lib/chain/chain";
import { friendlyFromApiError } 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;
}
// 多章工作流链页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 (
chainKey: ChainKind,
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: chainKey },
},
body: { start_chapter_no: startChapterNo, count },
},
);
if (error || !data) {
toast(friendlyFromApiError(error).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]);
// 链失败重试:重启轮询同一 job覆盖轮询/网络抖动这类瞬时失败;图从检查点续)。
const onRetry = useCallback((): void => {
if (jobId) poll.poll(jobId);
}, [jobId, poll]);
const showProgress = jobId !== null && result !== null;
// 链运行中/等待裁决时禁止重复发起。
const busy =
starting ||
(jobId !== null && (phase === "running" || phase === "awaiting"));
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={(k, s, c) => void onStart(k, 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" ? (
<StatusNote variant="danger" title="链运行失败">
<p>{poll.error ?? "请稍后重试。"}</p>
{jobId !== null ? (
<Button
variant="outline"
size="sm"
onClick={onRetry}
className="mt-3"
>
</Button>
) : null}
</StatusNote>
) : null}
{phase === "completed" ? (
<StatusNote variant="success" title="本链已全部跑完">
<p></p>
</StatusNote>
) : null}
</div>
</AppShell>
);
}