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

@@ -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无凭据 → 503dep 解析阶段拦下);
* `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。
*
* 项目不存在 → 404job 不存在 / 不属于该项目 → 404同案不枚举非 awaiting 态 →
* 409CONFLICT。`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 受理回执202job_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 链受理回执202job_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;

View File

@@ -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 =

View 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: [] });
});
});

View 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 的裁决组装逻辑DRYresume.decisions 与
// accept.decisions 同为 ConflictDecision[]),仅丢弃 final_text链续跑不带终稿
export function buildResumeRequest(
drafts: readonly DecisionDraft[],
): ChainResumeRequest {
const { decisions } = buildAcceptRequest("", drafts);
return { decisions };
}

View File

@@ -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",

View File

@@ -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" },