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:
20
apps/web/app/projects/[id]/chains/page.tsx
Normal file
20
apps/web/app/projects/[id]/chains/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { ChainPage } from "@/components/chain/ChainPage";
|
||||
import { fetchProject } from "@/lib/api/server";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
// 多章工作流链页(Scope B B1)。Server Component 取项目;ChainPage(Client)承载
|
||||
// 发起 → job 轮询进度 → interrupt 裁决 → resume 续跑交互。
|
||||
export default async function ChainsRoutePage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const project = await fetchProject(id);
|
||||
return <ChainPage project={project} />;
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
176
apps/web/components/chain/ChainAdjudication.tsx
Normal file
176
apps/web/components/chain/ChainAdjudication.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { ConflictCard } from "@/components/review/ConflictCard";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { buildResumeRequest } from "@/lib/chain/chain";
|
||||
import { friendlyError } from "@/lib/errors/messages";
|
||||
import type { ReviewConflict } from "@/lib/review/sse";
|
||||
import {
|
||||
allResolved,
|
||||
emptyDecisions,
|
||||
setNote,
|
||||
setVerdict,
|
||||
unresolvedIndices,
|
||||
type DecisionDraft,
|
||||
type Verdict,
|
||||
} from "@/lib/review/decisions";
|
||||
import { latestReview, normalizeConflicts } from "@/lib/review/history";
|
||||
|
||||
interface ChainAdjudicationProps {
|
||||
projectId: string;
|
||||
jobId: string;
|
||||
// interrupt 命中的章号(读其最近审稿冲突供裁决)。
|
||||
chapterNo: number;
|
||||
// 续跑发起后回调(父层重启轮询)。
|
||||
onResumed: () => void;
|
||||
}
|
||||
|
||||
type LoadState = "loading" | "ready" | "error";
|
||||
|
||||
// 链裁决面板(净新但复用满):读 awaiting 章最近审稿 → 渲 ConflictCard(每冲突一张,复用审稿页
|
||||
// 组件 + decisions.ts 纯逻辑)→ 全决后 POST resume(buildResumeRequest 复用 accept 裁决组装)。
|
||||
// 链无在手草稿正文 → 无段内定位(snippet=null/locatable=false),裁决纯靠 suggestion + 补丁对。
|
||||
export function ChainAdjudication({
|
||||
projectId,
|
||||
jobId,
|
||||
chapterNo,
|
||||
onResumed,
|
||||
}: ChainAdjudicationProps) {
|
||||
const toast = useToast();
|
||||
const [loadState, setLoadState] = useState<LoadState>("loading");
|
||||
const [conflicts, setConflicts] = useState<ReviewConflict[]>([]);
|
||||
const [drafts, setDrafts] = useState<DecisionDraft[]>([]);
|
||||
const [resuming, setResuming] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoadState("loading");
|
||||
void (async () => {
|
||||
try {
|
||||
const { data, error } = await api.GET(
|
||||
"/projects/{project_id}/chapters/{chapter_no}/reviews",
|
||||
{
|
||||
params: {
|
||||
path: { project_id: projectId, chapter_no: chapterNo },
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!active) return;
|
||||
if (error || !data) {
|
||||
setLoadState("error");
|
||||
return;
|
||||
}
|
||||
const next = normalizeConflicts(latestReview(data.reviews));
|
||||
setConflicts(next);
|
||||
setDrafts(emptyDecisions(next.length));
|
||||
setLoadState("ready");
|
||||
} catch {
|
||||
if (active) setLoadState("error");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [projectId, chapterNo]);
|
||||
|
||||
const onVerdict = useCallback((index: number, verdict: Verdict): void => {
|
||||
setDrafts((prev) => setVerdict(prev, index, verdict));
|
||||
}, []);
|
||||
|
||||
const onNote = useCallback((index: number, note: string): void => {
|
||||
setDrafts((prev) => setNote(prev, index, note));
|
||||
}, []);
|
||||
|
||||
const onResume = useCallback(async (): Promise<void> => {
|
||||
if (!allResolved(drafts)) {
|
||||
toast("仍有未裁决的冲突,请先逐条裁决。", "error");
|
||||
return;
|
||||
}
|
||||
setResuming(true);
|
||||
try {
|
||||
const { error } = await api.POST(
|
||||
"/projects/{project_id}/chains/runs/{job_id}/resume",
|
||||
{
|
||||
params: { path: { project_id: projectId, job_id: jobId } },
|
||||
body: buildResumeRequest(drafts),
|
||||
},
|
||||
);
|
||||
if (error) {
|
||||
toast(friendlyError(undefined).text, "error");
|
||||
setResuming(false);
|
||||
return;
|
||||
}
|
||||
onResumed();
|
||||
} catch {
|
||||
toast("续跑请求异常,请检查网络。", "error");
|
||||
setResuming(false);
|
||||
}
|
||||
}, [drafts, projectId, jobId, onResumed, toast]);
|
||||
|
||||
if (loadState === "loading") {
|
||||
return <p className="text-sm text-ink-soft">加载第 {chapterNo} 章冲突…</p>;
|
||||
}
|
||||
if (loadState === "error") {
|
||||
return (
|
||||
<p className="text-sm text-conflict">
|
||||
加载第 {chapterNo} 章审稿冲突失败,请刷新重试。
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const pending = unresolvedIndices(drafts);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-3 rounded border border-line bg-panel p-5"
|
||||
aria-label={`第 ${chapterNo} 章冲突裁决`}
|
||||
>
|
||||
<h2 className="font-serif text-lg text-ink">
|
||||
第 {chapterNo} 章冲突裁决({conflicts.length})
|
||||
</h2>
|
||||
{conflicts.length === 0 ? (
|
||||
<p className="text-sm text-ink-soft">
|
||||
该章无未决冲突,可直接续跑。
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{conflicts.map((conflict, i) => {
|
||||
const draft = drafts[i] ?? { verdict: null, note: "" };
|
||||
return (
|
||||
<ConflictCard
|
||||
key={i}
|
||||
index={i}
|
||||
conflict={conflict}
|
||||
draft={draft}
|
||||
missing={false}
|
||||
snippet={null}
|
||||
locatable={false}
|
||||
rewriting={false}
|
||||
onVerdict={onVerdict}
|
||||
onNote={onNote}
|
||||
onJump={() => {}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onResume()}
|
||||
disabled={resuming || !allResolved(drafts)}
|
||||
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{resuming ? "续跑中…" : "裁决完成,续跑"}
|
||||
</button>
|
||||
{pending.length > 0 ? (
|
||||
<p className="text-xs text-conflict">
|
||||
还有 {pending.length} 条冲突未裁决。
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
63
apps/web/components/chain/ChainProgress.tsx
Normal file
63
apps/web/components/chain/ChainProgress.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import type { ChainPhase, ChainResultView } from "@/lib/chain/chain";
|
||||
|
||||
interface ChainProgressProps {
|
||||
phase: ChainPhase;
|
||||
progress: number;
|
||||
result: ChainResultView;
|
||||
}
|
||||
|
||||
const PHASE_LABEL: Record<ChainPhase, string> = {
|
||||
running: "运行中",
|
||||
awaiting: "等待裁决",
|
||||
completed: "已完成",
|
||||
failed: "已失败",
|
||||
};
|
||||
|
||||
// 链进度视图:相位徽标 + 进度条 + 已写章号清单(token/正文绝不展示,result 只含元数据)。
|
||||
export function ChainProgress({ phase, progress, result }: ChainProgressProps) {
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-3 rounded border border-line bg-panel p-5"
|
||||
aria-label="链进度"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="font-serif text-lg text-ink">链进度</h2>
|
||||
<span
|
||||
className="rounded bg-cinnabar/10 px-2 py-0.5 text-xs text-cinnabar"
|
||||
aria-live="polite"
|
||||
>
|
||||
{PHASE_LABEL[phase]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-2 w-full overflow-hidden rounded bg-bg"
|
||||
role="progressbar"
|
||||
aria-valuenow={progress}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-cinnabar transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-ink-soft">
|
||||
已完成 {result.written.length} 章
|
||||
{result.written.length > 0 ? (
|
||||
<span className="ml-1 font-mono text-ink">
|
||||
({result.written.join("、")})
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
{phase === "awaiting" && result.awaitingChapter !== null ? (
|
||||
<p className="text-sm text-conflict">
|
||||
第 {result.awaitingChapter} 章存在未决冲突,请在下方逐条裁决后续跑。
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
80
apps/web/components/chain/ChainStarter.tsx
Normal file
80
apps/web/components/chain/ChainStarter.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
interface ChainStarterProps {
|
||||
// 发起一条链(起始章 + 章数);父层负责 POST run + 轮询。
|
||||
onStart: (startChapterNo: number, count: number) => void;
|
||||
// 链正在运行/等待裁决时禁用(避免重复发起)。
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_START = 1;
|
||||
const DEFAULT_COUNT = 3;
|
||||
const MAX_COUNT = 50;
|
||||
|
||||
// 链发起表单(净新):选起始章号 + 连续写几章 → 调 onStart。
|
||||
// count 1..50(对齐后端 ChainRunRequest Field 约束;前端先拦一道,越界后端 422 兜底)。
|
||||
export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
|
||||
const [start, setStart] = useState(String(DEFAULT_START));
|
||||
const [count, setCount] = useState(String(DEFAULT_COUNT));
|
||||
|
||||
const startNo = Number.parseInt(start, 10);
|
||||
const countNo = Number.parseInt(count, 10);
|
||||
const valid =
|
||||
Number.isInteger(startNo) &&
|
||||
startNo >= 1 &&
|
||||
Number.isInteger(countNo) &&
|
||||
countNo >= 1 &&
|
||||
countNo <= MAX_COUNT;
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4 rounded border border-line bg-panel p-5"
|
||||
aria-label="发起多章链"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (valid && !disabled) onStart(startNo, countNo);
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 className="font-serif text-lg text-ink">连续写多章</h2>
|
||||
<p className="mt-1 text-sm text-ink-soft">
|
||||
从指定章起循环「写章 → 四审 → 验收」;遇未决冲突会暂停等你裁决再续跑。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="block text-sm text-ink-soft">
|
||||
起始章号
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={start}
|
||||
onChange={(e) => setStart(e.target.value)}
|
||||
className="mt-1 w-32 rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
|
||||
aria-label="起始章号"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-ink-soft">
|
||||
连续章数(1..{MAX_COUNT})
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_COUNT}
|
||||
value={count}
|
||||
onChange={(e) => setCount(e.target.value)}
|
||||
className="mt-1 w-32 rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
|
||||
aria-label="连续章数"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled || !valid}
|
||||
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{disabled ? "运行中…" : "发起多章链"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
226
apps/web/lib/api/schema.d.ts
vendored
226
apps/web/lib/api/schema.d.ts
vendored
@@ -549,6 +549,55 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{project_id}/chains/{chain_key}/run": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Run Chain
|
||||
* @description 发起多章链:写一行 job 返 202,链经 BackgroundTask 异步跑。
|
||||
*
|
||||
* 项目不存在 → 404;未知 chain_key → 404;无凭据 → 503(dep 解析阶段拦下);
|
||||
* `count` 越界由 schema Field 在解析阶段 → 422。`gateway` 仅做请求阶段凭据探测——
|
||||
* 链本体在 BackgroundTask 里用独立 session 重建网关跑(请求 session 即将关闭)。
|
||||
*/
|
||||
post: operations["run_chain_projects__project_id__chains__chain_key__run_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{project_id}/chains/runs/{job_id}/resume": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Resume Chain
|
||||
* @description 裁决续跑:仅当 job=awaiting_input,带裁决经 BackgroundTask resume。
|
||||
*
|
||||
* 项目不存在 → 404;job 不存在 / 不属于该项目 → 404(同案,不枚举);非 awaiting 态 →
|
||||
* 409(CONFLICT)。`awaiting→running` 在 HTTP 处理器内**原子抢占**(条件 UPDATE),避免两个
|
||||
* 并发 resume 都过守卫后双 `Command(resume=...)` 损坏图(审评 #3);抢不到(已被并发抢走/非
|
||||
* awaiting)→ 409。resume 经 `Command(resume=decisions)` 从 interrupt 续跑(同 thread_id)。
|
||||
*/
|
||||
post: operations["resume_chain_projects__project_id__chains_runs__job_id__resume_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/settings/providers": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -721,6 +770,68 @@ export interface components {
|
||||
*/
|
||||
thinking: boolean;
|
||||
};
|
||||
/**
|
||||
* ChainResumeAccepted
|
||||
* @description resume 受理回执(202):job_id + chain_key(前端走 `GET /jobs/{id}` 轮询续跑进度)。
|
||||
*
|
||||
* 续跑无独立区间(图从检查点续),故不回显 start/count(会是无意义哨兵值)——只回 job 标识。
|
||||
*/
|
||||
ChainResumeAccepted: {
|
||||
/**
|
||||
* Job Id
|
||||
* Format: uuid
|
||||
*/
|
||||
job_id: string;
|
||||
/** Chain Key */
|
||||
chain_key: string;
|
||||
};
|
||||
/**
|
||||
* ChainResumeRequest
|
||||
* @description 裁决续跑:对 awaiting 章最近审稿的每个冲突逐条裁决(复用 `ConflictDecision`)。
|
||||
*/
|
||||
ChainResumeRequest: {
|
||||
/**
|
||||
* Decisions
|
||||
* @description 对 awaiting 章冲突的裁决清单(采纳/忽略/手改)
|
||||
*/
|
||||
decisions?: components["schemas"]["ConflictDecision"][];
|
||||
};
|
||||
/**
|
||||
* ChainRunAccepted
|
||||
* @description 链受理回执(202):job_id + 回显请求参数(前端走 `GET /jobs/{id}` 轮询进度/awaiting)。
|
||||
*/
|
||||
ChainRunAccepted: {
|
||||
/**
|
||||
* Job Id
|
||||
* Format: uuid
|
||||
*/
|
||||
job_id: string;
|
||||
/** Chain Key */
|
||||
chain_key: string;
|
||||
/** Start Chapter No */
|
||||
start_chapter_no: number;
|
||||
/** Count */
|
||||
count: number;
|
||||
};
|
||||
/**
|
||||
* ChainRunRequest
|
||||
* @description 发起多章链:从 `start_chapter_no` 起循环写 `count` 章(区间含两端)。
|
||||
*
|
||||
* `count` 1..50(>50 → 422 VALIDATION,由 Field 约束在边界拦下,不进 BackgroundTask);
|
||||
* `start_chapter_no >= 1`。
|
||||
*/
|
||||
ChainRunRequest: {
|
||||
/**
|
||||
* Start Chapter No
|
||||
* @description 起始章号(含),>=1
|
||||
*/
|
||||
start_chapter_no: number;
|
||||
/**
|
||||
* Count
|
||||
* @description 本 run 连续写的章数(1..50,含起始章)
|
||||
*/
|
||||
count: number;
|
||||
};
|
||||
/**
|
||||
* CharacterCardView
|
||||
* @description 单张角色卡(贴 ww_agents.CharacterCard;预览 + 入库请求共用形)。
|
||||
@@ -1723,9 +1834,14 @@ export interface components {
|
||||
* @default
|
||||
*/
|
||||
brief: string;
|
||||
/**
|
||||
* Text
|
||||
* @description 原文输入(扩写/降AI 的原文、拆书的样本;text_input 策略工具用)
|
||||
*/
|
||||
text?: string | null;
|
||||
/**
|
||||
* Chapter No
|
||||
* @description 按章展开类工具的章号(开篇/细纲)
|
||||
* @description 按章展开/续写类工具的章号(开篇/细纲/续写)
|
||||
*/
|
||||
chapter_no?: number | null;
|
||||
/**
|
||||
@@ -3074,6 +3190,114 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
run_chain_projects__project_id__chains__chain_key__run_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
project_id: string;
|
||||
chain_key: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ChainRunRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ChainRunAccepted"];
|
||||
};
|
||||
};
|
||||
/** @description 项目或链 key 不存在 */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 请求参数非法(count 越界等) */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 无可用 LLM 凭据 */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
resume_chain_projects__project_id__chains_runs__job_id__resume_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
project_id: string;
|
||||
job_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ChainResumeRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ChainResumeAccepted"];
|
||||
};
|
||||
};
|
||||
/** @description 项目或 job 不存在 */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description job 非 awaiting_input 态,不可续跑 */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_providers_settings_providers_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -30,6 +30,12 @@ export type ConflictDecision = components["schemas"]["ConflictDecision"];
|
||||
export type AcceptRequest = components["schemas"]["AcceptRequest"];
|
||||
export type AcceptResponse = components["schemas"]["AcceptResponse"];
|
||||
|
||||
// Scope B 多章工作流链(发起 / 续跑;进度走 GET /jobs/{id} 轮询)。
|
||||
export type ChainRunRequest = components["schemas"]["ChainRunRequest"];
|
||||
export type ChainRunAccepted = components["schemas"]["ChainRunAccepted"];
|
||||
export type ChainResumeRequest = components["schemas"]["ChainResumeRequest"];
|
||||
export type ChainResumeAccepted = components["schemas"]["ChainResumeAccepted"];
|
||||
|
||||
// M3 伏笔 + 大纲(C3 扩 T3.2/T3.5)。
|
||||
export type ForeshadowView = components["schemas"]["ForeshadowView"];
|
||||
export type ForeshadowBoardResponse =
|
||||
|
||||
112
apps/web/lib/chain/chain.test.ts
Normal file
112
apps/web/lib/chain/chain.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { emptyDecisions, setNote, setVerdict } from "@/lib/review/decisions";
|
||||
import type { JobView } from "@/lib/jobs/job";
|
||||
import {
|
||||
buildResumeRequest,
|
||||
chainPhase,
|
||||
chainResultView,
|
||||
} from "./chain";
|
||||
|
||||
function job(result: Record<string, unknown> | null): JobView {
|
||||
return {
|
||||
id: "j1",
|
||||
kind: "chain",
|
||||
status: "running",
|
||||
progress: 0,
|
||||
result,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("chainResultView", () => {
|
||||
it("narrows a completed chain result", () => {
|
||||
const view = chainResultView(
|
||||
job({
|
||||
chain_key: "draft_volume",
|
||||
written: [1, 2, 3],
|
||||
completed: true,
|
||||
awaiting_chapter: null,
|
||||
}),
|
||||
);
|
||||
expect(view).toEqual({
|
||||
chainKey: "draft_volume",
|
||||
written: [1, 2, 3],
|
||||
awaitingChapter: null,
|
||||
completed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("narrows an awaiting result (interrupt on a chapter)", () => {
|
||||
const view = chainResultView(
|
||||
job({
|
||||
chain_key: "draft_volume",
|
||||
written: [1],
|
||||
completed: false,
|
||||
awaiting_chapter: 2,
|
||||
}),
|
||||
);
|
||||
expect(view.awaitingChapter).toBe(2);
|
||||
expect(view.completed).toBe(false);
|
||||
expect(view.written).toEqual([1]);
|
||||
});
|
||||
|
||||
it("returns safe defaults for null/garbage result", () => {
|
||||
expect(chainResultView(job(null))).toEqual({
|
||||
chainKey: null,
|
||||
written: [],
|
||||
awaitingChapter: null,
|
||||
completed: false,
|
||||
});
|
||||
expect(
|
||||
chainResultView(job({ written: "nope", awaiting_chapter: "x" })).written,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps only finite integer chapter numbers in written", () => {
|
||||
const view = chainResultView(
|
||||
job({ written: [1, "2", 3.5, null, 4] }),
|
||||
);
|
||||
expect(view.written).toEqual([1, 4]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chainPhase", () => {
|
||||
it("maps polling with no awaiting chapter to running", () => {
|
||||
expect(chainPhase("polling", null)).toBe("running");
|
||||
});
|
||||
|
||||
it("maps any awaiting chapter to awaiting (interrupt signal is authoritative)", () => {
|
||||
// 后端 interrupt 时置 status=awaiting_input(被 narrowJob 抹平成 queued→仍 polling),
|
||||
// 但 result.awaiting_chapter 已置数;运行中该值恒 null,故它是暂停的权威信号。
|
||||
expect(chainPhase("polling", 2)).toBe("awaiting");
|
||||
expect(chainPhase("done", 2)).toBe("awaiting");
|
||||
});
|
||||
|
||||
it("maps done + no awaiting to completed", () => {
|
||||
expect(chainPhase("done", null)).toBe("completed");
|
||||
});
|
||||
|
||||
it("maps error to failed (even if an awaiting chapter lingers)", () => {
|
||||
expect(chainPhase("error", null)).toBe("failed");
|
||||
expect(chainPhase("error", 2)).toBe("failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildResumeRequest", () => {
|
||||
it("includes only resolved decisions and trims notes (reuses accept decision logic)", () => {
|
||||
let drafts = emptyDecisions(3);
|
||||
drafts = setVerdict(drafts, 0, "accept");
|
||||
drafts = setVerdict(drafts, 2, "manual");
|
||||
drafts = setNote(drafts, 2, " 改成后天淬炼 ");
|
||||
const req = buildResumeRequest(drafts);
|
||||
expect(req.decisions).toEqual([
|
||||
{ conflict_index: 0, verdict: "accept" },
|
||||
{ conflict_index: 2, verdict: "manual", note: "改成后天淬炼" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("empty drafts → empty decisions list", () => {
|
||||
expect(buildResumeRequest([])).toEqual({ decisions: [] });
|
||||
});
|
||||
});
|
||||
67
apps/web/lib/chain/chain.ts
Normal file
67
apps/web/lib/chain/chain.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// 多章工作流链前端纯逻辑(Scope B):把 `GET /jobs/{id}` 的链 job result 安全收窄成
|
||||
// 视图模型;把轮询状态 + interrupt 标志映射成链相位;裁决草稿 → resume 请求体。
|
||||
// 纯逻辑(无 React / 无 fetch),便于 node 环境单测。链走 job 轮询(非 SSE)。
|
||||
|
||||
import type { ChainResumeRequest } from "@/lib/api/types";
|
||||
import type { JobView, PollStatus } from "@/lib/jobs/job";
|
||||
import { buildAcceptRequest, type DecisionDraft } from "@/lib/review/decisions";
|
||||
|
||||
// 链 job result 的视图模型(与后端 `_chain_result` 字段对齐:chain_key/written/completed/
|
||||
// awaiting_chapter)。token/正文绝不入 result(不变量 #5 / chain §5),故此处只有进度元数据。
|
||||
export interface ChainResultView {
|
||||
chainKey: string | null;
|
||||
// 已写完(已验收/已晋升)的章号清单。
|
||||
written: number[];
|
||||
// interrupt 命中、等作者裁决的章号;null = 未暂停(跑完或运行中)。
|
||||
awaitingChapter: number | null;
|
||||
// 全链跑完(无 interrupt 残留)。
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
function asIntList(v: unknown): number[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.filter(
|
||||
(n): n is number => typeof n === "number" && Number.isInteger(n),
|
||||
);
|
||||
}
|
||||
|
||||
function asIntOrNull(v: unknown): number | null {
|
||||
return typeof v === "number" && Number.isInteger(v) ? v : null;
|
||||
}
|
||||
|
||||
// 把链 job 的 result 收窄成 ChainResultView(缺字段/脏数据给安全默认,守边界 #not-trust)。
|
||||
export function chainResultView(job: JobView): ChainResultView {
|
||||
const r = job.result ?? {};
|
||||
return {
|
||||
chainKey: typeof r["chain_key"] === "string" ? r["chain_key"] : null,
|
||||
written: asIntList(r["written"]),
|
||||
awaitingChapter: asIntOrNull(r["awaiting_chapter"]),
|
||||
completed: r["completed"] === true,
|
||||
};
|
||||
}
|
||||
|
||||
// 链相位(驱动页面 UI 分支):运行中 / 待裁决 / 跑完 / 失败。
|
||||
export type ChainPhase = "running" | "awaiting" | "completed" | "failed";
|
||||
|
||||
// 后端 interrupt 时把 job 置 awaiting_input(非 done/failed)+ result.awaiting_chapter=N——
|
||||
// `narrowJob` 把未知态 awaiting_input 归到 "queued",故轮询 reducer 仍 polling;运行中
|
||||
// awaiting_chapter 恒为 null,仅 interrupt 命中才置数,故以 `awaitingChapter !== null` 为
|
||||
// 暂停的权威信号(不依赖被抹平的原始状态),让页面在暂停时进入裁决相而非空轮询(不变量 #5)。
|
||||
export function chainPhase(
|
||||
pollStatus: PollStatus,
|
||||
awaitingChapter: number | null,
|
||||
): ChainPhase {
|
||||
if (pollStatus === "error") return "failed";
|
||||
if (awaitingChapter !== null) return "awaiting";
|
||||
if (pollStatus === "done") return "completed";
|
||||
return "running";
|
||||
}
|
||||
|
||||
// 裁决草稿 → resume 请求体。复用 accept 的裁决组装逻辑(DRY;resume.decisions 与
|
||||
// accept.decisions 同为 ConflictDecision[]),仅丢弃 final_text(链续跑不带终稿)。
|
||||
export function buildResumeRequest(
|
||||
drafts: readonly DecisionDraft[],
|
||||
): ChainResumeRequest {
|
||||
const { decisions } = buildAcceptRequest("", drafts);
|
||||
return { decisions };
|
||||
}
|
||||
@@ -10,15 +10,16 @@ import {
|
||||
const PID = "p1";
|
||||
|
||||
describe("projectNavItems", () => {
|
||||
it("builds the nine project entries scoped to the project id", () => {
|
||||
it("builds the ten project entries scoped to the project id", () => {
|
||||
const items = projectNavItems(PID);
|
||||
|
||||
expect(items).toHaveLength(9);
|
||||
expect(items).toHaveLength(10);
|
||||
expect(items.map((i) => i.key)).toEqual([
|
||||
"write",
|
||||
"outline",
|
||||
"foreshadow",
|
||||
"review",
|
||||
"chains",
|
||||
"style",
|
||||
"codex",
|
||||
"toolbox",
|
||||
|
||||
@@ -10,7 +10,8 @@ export type ActiveNav =
|
||||
| "codex"
|
||||
| "rules"
|
||||
| "skills"
|
||||
| "toolbox";
|
||||
| "toolbox"
|
||||
| "chains";
|
||||
|
||||
export interface NavItem {
|
||||
href: string;
|
||||
@@ -35,6 +36,7 @@ export function projectNavItems(projectId: string): NavItem[] {
|
||||
key: "foreshadow",
|
||||
},
|
||||
{ href: `/projects/${projectId}/review`, label: "审稿", key: "review" },
|
||||
{ href: `/projects/${projectId}/chains`, label: "多章链", key: "chains" },
|
||||
{ href: `/projects/${projectId}/style`, label: "文风", key: "style" },
|
||||
{ href: `/projects/${projectId}/codex`, label: "设定库", key: "codex" },
|
||||
{ href: `/projects/${projectId}/toolbox`, label: "工具箱", key: "toolbox" },
|
||||
|
||||
Reference in New Issue
Block a user